Resources
Hierarchical, asset-like entities and the relationships between them. Create resources and the edges between them in one call; the server returns the persisted graph.
A resource's externalId is its identity: unique per tenant, stored exactly as you send
it, and compared without case. Mirror the tag your operation already maintains —
COM-99-PT-1034 is stored as COM-99-PT-1034, not rewritten.
External ids & naming →
The resource body
| Field | Type | Notes |
|---|---|---|
id | number | Server-assigned. Crosses the wire as a JSON string — see the note below. |
externalId | string, 3–256 | Required. Unique per tenant, stored verbatim, matched case-insensitively. |
name | string, 3–512 | Required. What a human calls it. This is the field search reads. |
labels | string[] | Required, at least one. The type tags (Pump, Plant). Upper-cased by the server. |
description | string | Prose. |
metadata | map<string, string> | Flat key/value, filterable by exact match. |
source | string, 2–128 | The upstream system of record this came from (SAP, a historian, a file drop). |
dataSetId | number | The data set the resource belongs to. |
geoLocation | GeoJSON geometry | Point, Polygon, … Validated on write; stored verbatim. Returned only on assets. |
isRoot | boolean | Whether the resource is a navigation root. Deletes are checked against reachability from a root — see Delete. Returned only on resources and assets. |
relatedResources | object[] | Read-only view of the graph: { id, externalId, relationshipType, direction } per connected node. Populated where the graph is loaded, empty otherwise. |
createdTime, lastUpdatedTime | epoch millis | Server-set. A create body may carry them, but they are ignored: the stored values are the server's. |
Labels are how the platform types a node. The type-label (ASSET, TIMESERIES, DATASET,
POLICY, FUNCTION) is what the create pipeline reads to decide which kind of entity to
build, and free-form labels ride alongside it. That is also why one /resources/create call
can hold a mix of node types — a time-series next to an asset — rather than needing one
endpoint per type. The same label types what a read returns: see
Reads come back typed.
Two of those type-labels are privileged. A create carrying DATASET or POLICY builds a
data set or a policy, and managing those is stricter than writing data: it requires the
/datasets/*/write grant or DATAHUB_ADMIN, whichever endpoint the request arrives
through. Without it the call is a 403, even when you can write the data set named in
dataSetId. The same rule guards updating or deleting such a node via /resources, and
edges onto a data set node. See Access control.
id and dataSetId serialize as "5677892", not 5677892 — ids can exceed the 53-bit
integer a JSON number is safe for in JavaScript. The clients parse them back for you. The same
holds for the ids on an edge, start and end included.
Reads come back typed
The read endpoints (/resources/{id}, byids, filter, search, fetch-related,
fetch-nearest) return each node in the shape of its kind, and the type-label inside
labels is the discriminator. There is deliberately no separate type property on the wire:
an element whose labels contain TIMESERIES is the time-series shape.
| Type-label present | Shape returned |
|---|---|
ASSET | An asset: the body above, geoLocation included. |
TIMESERIES | A time-series: unit, unitExternalId, valueType. |
DATASET | A data set. |
POLICY | A policy: type, value, deactivated, templateId. |
FUNCTION | A function. |
| none | A plain resource, the body above. |
Three rules govern which fields appear where:
- A time-series carries its full label set, not only
["TIMESERIES"]. isRootbelongs to resources and assets;geoLocationbelongs to assets. A flat resource body naming ageoLocationis a400: a plain resource has nowhere to store one, so it is refused rather than accepted and dropped. Send anASSET-labelled body instead.- A policy carries no
nodeTypefield. ThePOLICYlabel is the type.
- Java
- Python
- Rust
These calls return DataWrapper<NodeModel>, and the concrete class of each item is the
subtype, so pattern-match to reach type-specific fields. fetchRelated/fetchNearest still
return a ResourceNetwork; its nodes are NodeModel too.
for (NodeModel node : client.resources().filter(retriever).getItems()) {
if (node instanceof Timeseries ts) {
System.out.println(ts.getExternalId() + " in " + ts.getUnit());
}
}
Each item is the same class the type's own endpoint returns, so isinstance works and a
time-series from resources.filter() behaves exactly like one from timeseries.by_ids().
Two new classes join the set: Asset and Policy.
from intellistream_datahub_sdk import TimeSeries
for node in client.resources.filter(external_id="pump_*"):
if isinstance(node, TimeSeries):
print(node.external_id, node.unit)
When you are dispatching from data rather than branching, every node class also carries
node_type, one of asset, timeseries, function, resource, dataset, policy:
by_type = {}
for node in client.resources.filter(external_id="pump_*"):
by_type.setdefault(node.node_type, []).append(node)
Reads return DataWrapper<Node> (or GraphDataWrapper<Node>), where Node is an enum with
one variant per type. Match it, or use the accessors for the fields every node shares.
use intellistream_datahub_sdk::Node;
for node in api.resources.filter(&form).await?.get_items() {
match node {
Node::TimeSeries(ts) => println!("{} in {:?}", ts.external_id, ts.unit),
other => println!("{} ({:?})", other.external_id(), other.kind()),
}
}
Node is #[non_exhaustive], so a node type added later is not a breaking change for a
match that already has a catch-all arm.
Create and update echoes are typed the same way, so an asset updated through
/resources/update comes back as an asset carrying its geoLocation. One difference on the
update echo: relatedResources is left empty on purpose. The request touched only some of the
node's edges, and answering with those alone would be indistinguishable from answering with all
of them. A delete has no echo at all, being a 204 with no body.
Look up
Fetch by numeric id or external id (you can mix them). Lookup ignores case, so pump_1 and
PUMP_1 resolve to the same resource; what comes back keeps the spelling it was created
with. Identifiers that match nothing are silently omitted rather than erroring, so
compare the returned items against what you asked for when a miss matters.
- Java
- Python
- Rust
import ai.intellistream.datahub.models.IdCollection;
NodeModel pump = client.resources().getById(5677892).getItems().iterator().next();
DataWrapper<NodeModel> some = client.resources().byIds(List.of(
IdCollection.createFromExternalId("pump_1"),
IdCollection.createFromId(5677892)));
# pass entity objects, external-id strings, or numeric ids
resources = client.resources.by_ids(["pump_1", 5677892])
use intellistream_datahub_sdk::generic::IdAndExtId;
let resources = api.resources.by_ids(&vec![
IdAndExtId::from_external_id("pump_1"),
IdAndExtId::from_id(5677892),
]).await?;
Create resources and relations
Pass the resource forms (nodes) and the relation forms (edges); the call returns the
created graph — nodes plus server-assigned edges. Each resource needs at least one
label (a type tag such as Plant or Pump) — a node with none is rejected with
400 resource.needs.at.least.one.label. Labels and relationship types are both
upper-cased by the server. External ids are not: they are stored verbatim.
The call is all-or-nothing. Every external id in the batch is validated before anything
is written, so one item rejected by the
naming policy means nothing is created and the 400
names every offending item, not just the first. If the policy is set to warn instead, the
response carries a warnings array next to
items.
One request carries at most 1 000 nodes and 1 000 relations, and one node at most 10 000
characters of description, 256 metadata entries, 64 labels and 64 KiB of raw GeoJSON in
geoLocation. Past any of those is a 400 before anything is written; the same caps apply on
update, to set and add alike. See Limits & quotas.
A relation may reference a node being created in the same request by its externalId, or
point at one that already exists. An edge whose endpoint is neither is a 400 naming the
endpoint it could not resolve.
Two more checks run over the whole batch before anything is written, on every node create
endpoint: /resources, /assets, /datasets, /functions, /policies and /timeseries
alike. Both refuse the whole request, so a rejected batch creates nothing.
| Refused | Status | Named in |
|---|---|---|
An externalId already taken in the tenant, or repeated within the same batch. Compared without case. | 409 | error.duplicated, one entry per offending id |
A dataSetId that does not exist, or that resolves to a node which is not a data set. | 400 | error.fields, one entry per offending id |
{
"error": {
"code": 409,
"message": "A node with that externalId already exists.",
"duplicated": [{ "externalId": "pump_1" }]
}
}
Use update to change an existing resource rather than re-creating it. Access is
decided before either check, so a caller who may not write the data set is told that (403)
instead of being handed a 400 about an id they were never allowed to name.
Two endpoint rules apply to every edge, on create and on update (an update can retarget an edge or change its type):
- A relation to a dataset must use the
BELONGS_TOrelationship type — that is the relation the dataset hierarchy and membership are built from, and anything else is rejected with a400. - A dataset → time-series edge is accepted only when the series has no dataset yet, or
already belongs to that very dataset (creating a series inside a dataset produces exactly
that membership edge). A series in a different dataset is rejected with a
400— a time-series has one dataset.
- Java
- Python
- Rust
ResourceForm plant = new ResourceForm();
plant.setExternalId("plant_oslo");
plant.setName("Oslo Plant");
plant.setLabels(List.of("Plant"));
ResourceForm pump = new ResourceForm();
pump.setExternalId("pump_1");
pump.setName("Pump 1");
pump.setLabels(List.of("Pump"));
RelForm contains = new RelForm();
contains.setName("contains");
contains.setFromExternalId("plant_oslo");
contains.setToExternalId("pump_1");
GraphDataWrapper<Resource, EdgeProxy> created = client.resources()
.create(List.of(plant, pump), List.of(contains));
System.out.println(created.getNodes().size() + " resources, "
+ created.getRelations().size() + " relations");
import intellistream_datahub_sdk
plant = intellistream_datahub_sdk.Resource(external_id="plant_oslo", name="Oslo Plant", labels=["Plant"])
pump = intellistream_datahub_sdk.Resource(external_id="pump_1", name="Pump 1", labels=["Pump"])
contains = intellistream_datahub_sdk.RelForm.by_external_ids("plant_oslo", "pump_1", "contains")
result = client.resources.create([plant, pump], [contains])
print(len(result.nodes), "resources,", len(result.relations), "relations")
use intellistream_datahub_sdk::resources::Resource;
use intellistream_datahub_sdk::relations::RelForm;
let mut plant = Resource::new();
plant.external_id = "plant_oslo".into();
plant.name = "Oslo Plant".into();
plant.labels = Some(vec!["Plant".into()]);
let mut pump = Resource::new();
pump.external_id = "pump_1".into();
pump.name = "Pump 1".into();
pump.labels = Some(vec!["Pump".into()]);
let contains = RelForm::by_external_ids("plant_oslo", "pump_1", "contains");
let created = api.resources.create(vec![plant, pump], vec![contains]).await?;
An edge comes back as a Relation — { id, start, end, type, description, metadata },
where start and end are the ids of the two nodes (as JSON strings, like every other id).
That is why you send fromExternalId/toExternalId but read start/end: the write side
speaks in your identifiers, the read side in the graph's.
Relations are directional. from → to is the direction you will see when you
traverse, so plant contains pump and pump contains plant
describe different graphs.
Relations without the nodes
There are two ways to create a relation and they produce the same edge. The call above sends
nodes and relations together, in one transaction. POST /edges/create sends the relations by
themselves, for when both ends already exist and repeating them would be noise — same fields,
same rules, same edges back.
That endpoint, and the rest of the /edges surface (reading an edge back, deleting one
without touching its endpoints, the relationship-type catalog), has its own page.
Edges →
To disconnect two resources without touching either of them, delete the edge. Deleting a resource is the heavier move: it takes every relation the resource had with it.
Filter
POST /resources/filter finds resources by structured criteria. Everything you supply is
combined with AND.
| Field | Matching |
|---|---|
name | Pattern, case-insensitive. * and % are wildcards, _ is literal. |
source | Pattern, on the same rules. |
externalId | Pattern, on the same rules. |
id | Exact numeric id. |
nodeType | Restrict to these node types. Omit for every type. |
isRoot | true or false. |
labels | Resources carrying all of these labels. |
dataSetId | Resources in any of these data sets. |
metadata | Every key/value given must be present on the resource. |
createdTime, lastUpdatedTime | { "min": …, "max": … }, ISO-8601, both bounds inclusive. |
Each field above except isRoot, labels and metadata takes either a bare value or an
array, and the entries of an array are combined with OR. That is why they are named in the
singular: "name": "pipe%" is the common case, and "name": ["pipe%", "valve%"] asks for either.
labels and metadata are the exceptions, requiring all entries to match, and they keep
plural names because adding an entry there narrows the result where adding a name widens it.
{
"limit": 100,
"filter": {
"name": "pipe%",
"dataSetId": [{ "id": 12 }, { "externalId": "data_set_sap" }],
"metadata": { "work_order": "wo-sap-12344" },
"createdTime": { "min": "2026-01-01T00:00:00Z" }
}
}
limit defaults to 1 000 and is capped at 10 000; a zero, negative or null value
falls back to the default rather than returning nothing. Results come newest created first
unless ordered otherwise, and page with a cursor — the same contract as
timeseries, over the same sortable properties.
"name": "pipe" matches a resource named exactly pipe, not every name containing it. Add a
wildcard for the loose match you probably want: "pipe*" for a prefix, "*pipe*" for a contains
search. The same holds for source and externalId.
dataSetId and sending [] are oppositesOmit the field (or send null) for no data set restriction. An explicit empty list means
narrow to no data sets, which matches nothing. Every other list field treats empty as "no
restriction", so this is the one to watch when you build the filter programmatically.
- Java
- Python
- Rust
ResourceRetreiver retriever = new ResourceRetreiver();
retriever.setLimit(100);
retriever.getFilter().setName(List.of("pipe%"));
retriever.getFilter().setMetadata(Map.of("work_order", "wo-sap-12344"));
retriever.getFilter().setDataSetId(List.of(IdCollection.createFromId(12L)));
DataWrapper<NodeModel> matches = client.resources().filter(retriever);
matches = client.resources.filter(
name="pipe%",
metadata={"work_order": "wo-sap-12344"},
data_set_id=[12],
limit=100)
use intellistream_datahub_sdk::filters::NodeFilter;
use intellistream_datahub_sdk::generic::IdAndExtId;
use intellistream_datahub_sdk::resources::{ResourceFilter, ResourceRetreiver};
// The criteria every node type shares are a flattened `NodeFilter`, so they nest in Rust even
// though they sit alongside the resource's own fields on the wire.
let retriever = ResourceRetreiver::new(ResourceFilter {
node: NodeFilter {
name: Some(vec!["pipe%".into()]),
metadata: Some([("work_order".into(), Some("wo-sap-12344".into()))].into()),
..Default::default()
},
data_set_id: Some(vec![IdAndExtId::from_id(12)]),
..Default::default()
}).with_limit(100);
let matches = api.resources.filter(&retriever).await?;
Search
Free-text search across every node type (assets, timeseries, functions, resources, data sets
and policies), the same breadth as POST /resources/filter. The phrase is matched against name,
externalId and description. Matching is fuzzy and word-aware: search pipe and you also get
pipes, piping, and multi-word names containing the term.
Results are ranked by relevance (ts_rank), strongest match first, with id as a tie-break so
equal-scoring rows keep a stable order and repeated identical searches agree. Ranking means the
database scores and sorts every match before applying limit, so a very broad phrase costs more
than a narrow one.
limit is capped at 1 000 here, lower than the 10 000 of filter, and query must be
3 to 140 characters.
Narrowing with filter
filter is optional and takes the same criteria as POST /resources/filter. It only ever removes
matches: the phrase decides what the candidates are. nodeType and dataSetId are applied by the
search query itself, everything else is applied to the hits afterwards.
{
"search": { "query": "pump" },
"filter": { "nodeType": ["timeseries"], "dataSetId": [{ "id": "12" }] },
"limit": 50
}
filter used to be accepted and silently ignored here, as it was on the data set and event
searches. All four searches now apply it.
The phrase and the filter are now one query, so the database plans them together. They were briefly two, with the phrase capped at a 10 000-row candidate set that the filter then narrowed, which quietly dropped matches past that cap.
Two other things moved with this. The search originally ran one query per node type and concatenated
the results, so limit applied per type (a request for 50 could return 250) and results came back
grouped by type. Policies were never searched at all, and now are, so a search with no nodeType
can return rows it did not before.
- Java
- Python
- Rust
ResourceSearch search = new ResourceSearch();
search.setLimit(10);
search.getSearch().setQuery("pump");
DataWrapper<NodeModel> matches = client.resources().search(search);
form = intellistream_datahub_sdk.SearchAndFilterForm(query="pump", limit=10)
matches = client.resources.search(form)
use intellistream_datahub_sdk::generic::{SearchAndFilterForm, SearchForm};
let form = SearchAndFilterForm {
search: Some(SearchForm { name: None, description: None, query: Some("pump".into()) }),
limit: Some(10),
filter: None,
};
let matches = api.resources.search(&form).await?;
Reach for filter instead whenever the question is structured — an exact external id, a
metadata value, a data set, a time range. It is faster and its results are predictable.
Update
POST /resources/update changes fields on resources and relations that already exist.
Identify each node by id or externalId, each relation by id, and name only what you
want changed — anything you leave out keeps its current value.
Each field is an object carrying a verb rather than a bare value, which is what lets "clear this" be said distinctly from "leave it alone":
| Verb | Applies to | Effect |
|---|---|---|
set | every field | Replace the value. |
setNull: true | nullable fields only | Clear the value. name and externalId are not nullable, so asking to clear either is a 400. |
add | metadata, labels | Merge entries in, keeping the rest. |
remove | metadata, labels | Take entries out, keeping the rest. |
{
"nodes": [
{
"externalId": "klp_pipe_ws_a1212_dl",
"update": {
"name": { "set": "klp pipe ws-a1212-dl (renamed)" },
"metadata": { "add": { "inspected_by": "olav" } },
"labels": { "add": ["CRITICAL"] }
}
}
],
"relations": []
}
Updatable node fields are externalId, name, description, source, dataSetId,
metadata, labels and geoLocation. On a relation they are start, end,
fromExternalId, toExternalId, relationship, relationshipId, description and
metadata — so an edge can be retargeted or retyped in place, subject to the same
endpoint rules as a create.
Sending both set and setNull for one field is a 400: the request is contradictory, so
it is refused rather than resolved by precedence. setNull against name or externalId
is also a 400, for the same reason a create cannot omit them: every resource has to have
both. Rename with set instead. (This used to return 200 and quietly change nothing, so
check the value rather than the status if you are working against an older deployment.)
Changing externalId runs it past the
naming policy, which reports violations per item in an
RFC 9457 problem response. The whole batch is all-or-nothing.
409 means someone else got there firstUpdates are guarded by optimistic locking. If another request changed or deleted the
resource while yours was in flight, you get a 409 with "cause": "concurrency" and
nothing was written — no partial application to unpick. Re-read the resource with
byIds and retry the update against fresh state.
This is worth designing for rather than retrying blindly: two writers doing
metadata: { add: … } can both succeed after a re-read, whereas two doing
metadata: { set: … } will keep clobbering each other however many times you retry.
All three clients wrap this: resources().update(nodes, relations) in Java,
resources.update([...]) in Python, and resources.update(&updates) in Rust, each taking the
per-entry update forms above.
Delete
Delete by id or external id; unknown identifiers are silently skipped. A successful delete
returns 204 with no body, and deleting something already gone is a no-op — so a retried
delete needs no bookkeeping.
Deleting a resource takes all of its relationships with it, inbound and outbound. That is where the one real constraint comes from:
A delete is rejected with 400 if it would leave any surviving resource unreachable from a
root resource — that is, if it would strand part of the graph. The response names the
resources that would be stranded, so the fix is either to include them in the same delete or
to re-attach them through another path first.
Delete a mid-level node in a hierarchy and this is what you will hit: removing a plant that holds twenty pumps takes the edges to those pumps with it, stranding all twenty. The check is what stops a routine cleanup from quietly orphaning half a site.
A single safety-check failure rolls the whole batch back — nothing is deleted unless
everything can be. As with update, a concurrent modification surfaces as a 409 with
nothing removed.
- Java
- Python
- Rust
client.resources().delete(List.of(IdCollection.createFromExternalId("pump_1")));
client.resources.delete(["pump_1"])
api.resources.delete(&vec![IdAndExtId::from_external_id("pump_1")]).await?;
Traverse the graph
fetchRelated walks the graph outward from a starting resource and returns the
connected sub-graph — a ResourceNetwork of nodes, the edges between them, and
their labels. Traversal is undirected and bounded by depth (-1 = the whole
connected component), optionally filtered to specific relationship types. Use it for
relationship reasoning — root-cause correlation, blast radius — that a flat lookup
can't do. See Correlate alarms with the graph.
| Field | Default | Meaning |
|---|---|---|
id / externalId | — | Where to start. Supply exactly one. |
depth | -1 | Hops to follow. -1 loads the entire connected component. |
relationshipTypes | all | Which edge types the walk may follow. |
excludedLabels | none | Labels the walk neither passes through nor returns — e.g. ["POLICY"] to keep governance nodes out of an asset view. |
limit | 5000 | Safety cap on nodes loaded. When the component is bigger, the nearest limit nodes come back. |
That limit is the one to watch: it is a silent truncation, not an error. On a densely
connected site an unbounded depth will hit 5 000 nodes long before it runs out of graph,
and what you get back is a neighbourhood, not the component you asked for. Bound depth
to 1–3 unless you know the graph is sparse.
Nodes from fetchRelated and fetch-nearest come back
typed by label but sparsely populated: the graph holds a subset of each
node's columns, so a node from these endpoints is not the full record. Fetch by id when you
need everything.
A TIMESERIES node from these endpoints carries unit, unitExternalId and valueType.
Every node carries its metadata.
- Java
- Python
- Rust
// convenience: within `depth` hops of an external id
ResourceNetwork net = client.resources().fetchRelated("sensor_a", 5);
// or the full form, filtering which relationship types to follow
RelatedResourcesForm form = new RelatedResourcesForm();
form.setExternalId("sensor_a");
form.setDepth(5);
form.setRelationshipTypes(List.of("PART_OF"));
ResourceNetwork filtered = client.resources().fetchRelated(form);
net.nodes().forEach(n -> System.out.println(n.getExternalId()));
net = client.resources.fetch_related(
external_id="sensor_a", depth=5, relationship_types=["PART_OF"])
for node in net.nodes:
print(node.external_id)
for edge in net.edges:
print(edge.start, "->", edge.end, edge.relationship_type)
use intellistream_datahub_sdk::resources::RelatedResourcesForm;
let net = api.resources.fetch_related(
&RelatedResourcesForm::from_external_id("sensor_a")
.with_depth(5)
.with_relationship_types(vec!["PART_OF".into()])).await?;
for node in net.nodes() {
println!("{}", node.external_id);
}
The nearest N of a kind
POST /resources/fetch-nearest answers a question fetchRelated cannot: the ten nearest
time-series to this pump. It walks breadth-first and caps on the number of matching
end-nodes, not on hops or total nodes — so "the 10 nearest TIMESERIES" is exactly ten
however many intermediate nodes lie between them. You get those nodes plus the sub-graph
connecting them back to the start.
| Field | Default | Meaning |
|---|---|---|
id | — | Where to start. Numeric id only — see below. |
endLabels | — | Labels that qualify as a match, e.g. ["TIMESERIES"]. The walk continues past them. |
limit | 10 | How many matching end-nodes to return. |
relationshipTypes | all | Which edge types the walk may follow. |
excludedLabels | none | Labels never traversed or returned. |
That is the difference worth internalising: with fetchRelated you pick a radius and find
out what is inside it, which on an unfamiliar graph is a guess. With fetch-nearest you name
what you are looking for and how many you want, and the radius follows.
externalId is accepted but not readThe request form carries an externalId field, but this endpoint starts from id only —
sending an external id alone gets you a 404. Resolve it to a numeric id with byIds first.
fetchRelated takes either.
- Java
- Python
- Rust
FetchNearestResourcesForm form = new FetchNearestResourcesForm();
form.setId(5677892L); // numeric id, not external id
form.setEndLabels(List.of("TIMESERIES"));
form.setLimit(10);
form.setExcludedLabels(List.of("POLICY"));
ResourceNetwork nearest = client.resources().fetchNearest(form);
nearest = client.resources.fetch_nearest(
5677892, # numeric id, not external id
end_labels=["TIMESERIES"],
limit=10,
excluded_labels=["POLICY"])
use intellistream_datahub_sdk::resources::FetchNearestResourcesForm;
let nearest = api.resources.fetch_nearest(
&FetchNearestResourcesForm::from_id(5677892) // numeric id, not external id
.with_end_labels(vec!["TIMESERIES".into()])
.with_limit(10)
.with_excluded_labels(vec!["POLICY".into()])).await?;
The /assets endpoints
An asset is the node type that can be a navigation root and the only one that carries a
geoLocation. It has its own endpoint family, and every call in it is the pipeline above with
the ASSET type pinned: the same ACLs, the same naming policy,
the same create checks, the same status codes. Reach for it
when a call should only ever see assets, and for /resources when one call carries or returns
several node types.
| Endpoint | Behaves as | Worth knowing |
|---|---|---|
POST /assets/create | create | Nodes only, no relations array. 201, and the echo is asset-shaped. labels may be omitted: ASSET is added for you. |
GET /assets/{id} | look up | One asset, wrapped in items like every other read. |
POST /assets/byids | look up | Ids that are missing, are not assets, or are not readable are omitted rather than failing the call. |
POST /assets/filter | filter | The same criteria, the same paging. A nodeType in the body is replaced, see below. |
POST /assets/search | search | Same replacement, and the filter block is still accepted and ignored. |
POST /assets/update | update | Takes nodes and relations exactly as /resources/update does. |
POST or DELETE /assets/delete | delete | 204, and the same connectivity check. |
POST /assets/create
{
"items": [
{
"externalId": "plant_oslo",
"name": "Oslo Plant",
"labels": ["Plant"],
"isRoot": true,
"geoLocation": { "type": "Point", "coordinates": [10.75, 59.91] }
}
]
}
nodeType in the body is replaced, not mergedA nodeType you send to /assets/filter or /assets/search is overwritten with asset.
nodeType entries are combined with OR, so honouring a supplied ["timeseries"] would
widen a request made to /assets into a mixed query instead of narrowing it. Ask
/resources/filter when you want a mixed set.
404 here answers three questions at onceGET /assets/{id} replies the same way to an id that does not exist, an id belonging to a node
of some other type, and an asset in a data set you may not read. That is deliberate: a
distinguishable 403 would confirm that an id exists.
The /functions endpoints
A function is a plain node distinguished by its FUNCTION label, with the same shape as a
resource. Its family is POST /functions/create, GET /functions/list, GET /functions/{id},
POST /functions/update and POST or DELETE /functions/delete, on the same shared pipeline.
GET /functions/list takes no filter: the inventory is expected to be small.
GET /functions/{id} is new, and completes the read surface: it returns the one function
wrapped in items, and reports a function you may not read as missing (404) rather than
forbidden, exactly as GET /assets/{id} does.
The Java client has no assets() or functions() service, so reach for the endpoints there.
Creating an asset through resources().create with an ASSET label is the same pipeline and
gives you the same asset back.
What each client covers
| Operation | Java | Python | Rust |
|---|---|---|---|
| Get by numeric id | resources().getById | resources.get_by_id | resources.get_by_id |
| Look up by id / external id | resources().byIds | resources.by_ids | resources.by_ids |
| Create | resources().create | resources.create | resources.create |
| Update | resources().update | resources.update | resources.update |
| Delete | resources().delete | resources.delete | resources.delete |
| Search | resources().search | resources.search | resources.search |
| Filter | resources().filter | resources.filter | resources.filter |
Traverse (fetch-related) | resources().fetchRelated | resources.fetch_related | resources.fetch_related |
Nearest N (fetch-nearest) | resources().fetchNearest | resources.fetch_nearest | resources.fetch_nearest |
Relations have their own client surface in all three clients — edges() in Java, edges in
Python and Rust. Edges → client coverage