Edges
The relationships between resources, as objects in their own right. An edge is directional
(from → to), 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
| Field | Type | Notes |
|---|---|---|
id | number | Server-assigned. Crosses the wire as a JSON string. |
start | number | Numeric id of the from node. |
end | number | Numeric id of the to node. |
type | string | The relationship type name, upper-cased (CONTAINS, FLOWS_TO). |
relationshipTypeId | number | The type's id in the catalog. JSON string on the wire. |
description | string | Prose. |
metadata | map<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.
- Java
- Python
- Rust
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.
import datahub_sdk
contains = datahub_sdk.RelForm(
relationship_type="CONTAINS",
from_external_id="plant_oslo",
to_external_id="pump_1",
description="Feeds the east wing",
)
created = client.edges.create([contains])
print(created[0].id)
RelForm.by_external_ids(from_external_id, to_external_id, relationship_type) is the short
form when you only need the three; RelForm.by_ids is its numeric-id counterpart. The service
unwraps for you — create hands back a plain list[EdgeProxy], not a wrapper.
use dataplatform_rust_sdk::relations::RelForm;
let contains = RelForm::by_external_ids("plant_oslo", "pump_1", "CONTAINS");
let created = api.edges.create(&vec![contains]).await?;
println!("{:?}", created.get_items()[0].id);
RelForm::by_ids(from, to, ty) is the numeric-id counterpart.
When it fails
| Status | Means |
|---|---|
400 | An end doesn't exist (the message names which), the relation has no type, or it breaks one of the graph rules below. |
403 | You 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. |
409 | The two are already connected that way. (start, end, type) is unique — one relation per pair per type. |
The same two rules the graph create enforces apply here:
- A relation to a dataset must use the
BELONGS_TOrelationship type — that is the relation dataset membership is built from, and anything else is a400. - 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.
- Java
- Python
- Rust
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());
}
one = client.edges.get(341) # EdgeProxy, or None if nothing has that id
many = client.edges.by_ids([341, 342])
for endpoint in many.nodes:
print(endpoint.external_id)
Edges have no external id, so by_ids and delete take numeric ids — or an EdgeProxy you
already hold, which is accepted anywhere an id is.
use dataplatform_rust_sdk::generic::IdAndExtId;
let one = api.edges.get(341).await?;
let many = api.edges.by_ids(&vec![IdAndExtId::from_id(341)]).await?;
for endpoint in many.nodes().unwrap_or_default() {
println!("{}", endpoint.external_id);
}
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.
- Java
- Python
- Rust
client.edges().delete(List.of(IdCollection.createFromId(341)));
client.edges.delete([341])
use dataplatform_rust_sdk::generic::IdAndExtId;
api.edges.delete(&vec![IdAndExtId::from_id(341)]).await?;
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 }.
- Java
- Python
- Rust
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));
import datahub_sdk
catalog = client.edges.types()
client.edges.create_types([
datahub_sdk.RelTypeForm("FLOWS_TO", description="Flow direction"),
])
use dataplatform_rust_sdk::relations::RelTypeForm;
let catalog = api.edges.types().await?;
api.edges.create_types(&vec![RelTypeForm::new("FLOWS_TO")]).await?;
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.
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
| Operation | Java | Python | Rust |
|---|---|---|---|
| Create relations | edges().create | edges.create | edges.create |
| Get by id | edges().findById | edges.get | edges.get |
| Look up several, with endpoints | edges().byIds | edges.by_ids | edges.by_ids |
| Delete | edges().delete | edges.delete | edges.delete |
| List types | edges().types | edges.types | edges.types |
| Create types | edges().createTypes | edges.create_types | edges.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.