JSON-LD
Every NGSI-LD request and answer is JSON-LD, ordinary JSON with one extra member, @context, that says what the names in it mean. You can write NGSI-LD for a while without thinking about it, because the broker fills in sensible defaults. Sooner or later, though, a query matches nothing or an answer comes back full of long URLs, and the @context is nearly always why.
This guide explains what JSON-LD is, how it turns short names into meanings, and why NGSI-LD uses it. It then shows how to send a @context, how to store one on the broker, and what to expect when you read data back.
Names need meanings
Say two systems report on the same car. One writes as follows.
{
"brandName": "Mercedes"
}
The other, built by a French team, writes as follows.
{
"marque": "Mercedes"
}
A person sees that both mean the same thing. A program doesn't. And the opposite problem is just as real. Two systems can both write temperature, one meaning the air in a room and the other the body of a patient. Plain JSON has no way to say what a key means, so every integration ends up with a hand-written mapping between the names one system uses and the names another expects.
That is manageable inside one application. It falls apart when data from many cities, vendors and devices meets in one place, which is exactly what a context broker is for.
IRIs as names
The fix that the web settled on is to name things with IRIs, the general form of a URL. Instead of brandName, the name is https://example.org/vehicle/brandName. An IRI is globally unique, because whoever owns the domain decides what is published under it, so two vocabularies can't collide by accident. It can also lead somewhere, since a well-run vocabulary serves a definition at that address.
Nobody wants to write full IRIs as JSON keys, though, and nobody wants to read them. JSON-LD keeps the short names you are used to and moves the IRIs into a separate dictionary, the @context.
The @context
A @context maps short names, which JSON-LD calls terms, to IRIs.
{
"@context": {
"brandName": "https://example.org/vehicle/brandName"
},
"brandName": "Mercedes"
}
A JSON-LD processor reads that document by expansion. It replaces every term with the IRI the @context gives it and drops the @context, since its job is done. The result is what the document actually says.
[
{
"https://example.org/vehicle/brandName": [
{
"@value": "Mercedes"
}
]
}
]
Now take the French document with a @context of its own:
{
"@context": {
"marque": "https://example.org/vehicle/brandName"
},
"marque": "Mercedes"
}
It expands to exactly the same thing. The two systems use different words, but they map them to the same IRI, so they say the same thing, and a program can tell.
Compaction is the way back. It takes expanded data and a @context, and writes each IRI as the term that @context gives it. Compact the expanded document above with the first @context and you get brandName; compact it with the second and you get marque. So the writer and the reader don't need to share short names, only IRIs. Each side picks its own @context.
A few more things are worth knowing before NGSI-LD enters the picture.
- A
@contextis usually not written inline but published as a document, referenced by its URL, as in"@context": "https://example.org/vehicle.jsonld". Vocabularies such as the Smart Data Models publish theirs this way. - A
@contextcan be an array of URLs and inline objects. They apply in order, and when two of them define the same term, the later one wins. @idand@typeare JSON-LD keywords, not terms. They mean "the identifier of this thing" and "what kind of thing it is" in every document.- A term that no
@contextdefines can still be expanded if a@contextsets a default namespace, called@vocab. Without one, the processor drops the member.
Why NGSI-LD is built on JSON-LD
NGSI-LD is meant for data that many parties produce and many others read, and JSON-LD is what lets that work.
- Data from different producers fits together. Two entities that use different short names for the same IRI are queried, filtered and subscribed to as one.
- You query by meaning, not by spelling. A query names terms, the broker expands them under your
@context, and it matches the IRIs, whatever words the writers used. - It is standard linked data. NGSI-LD entities convert to RDF and can point at published vocabularies, so they plug into tools far beyond NGSI-LD. That is the "LD" in the name.
NGSI-LD's own words, such as id, type, Property, Relationship, value, object and observedAt, are terms too. They are defined by the core @context, which ETSI publishes at the following address.
https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld
The core @context maps id to @id and type to @type, value to https://uri.etsi.org/ngsi-ld/hasValue, and so on. It differs from any @context you write.
- It always applies. You never have to send it, and if you do, the broker treats it as if it came last.
- It is protected. Your
@contextcan't redefinetype,valueor any other core term. - It sets
@vocabtohttps://uri.etsi.org/ngsi-ld/default-context/, so a name your@contextdoesn't define still means something, andbrandNamewith no definition becomeshttps://uri.etsi.org/ngsi-ld/default-context/brandName.
Your own @context sits in front of it and defines the rest, one term for each entity type and one for each attribute name you use.
What the broker does with your names
The broker keeps no short names. Every write is expanded with the @context of the request, and what is stored is the expanded data, where the entity's type, its attribute names and the NGSI-LD terms are all IRIs.
Every answer is compacted again, with the @context of the request that reads it. That is the reader's @context, not the writer's. The broker never remembers which @context an entity was written with.
This catches most people out at least once.
- A reader who sends the writer's
@contextsees the writer's names. - A reader who sends a different
@contextthat maps the same IRIs sees their own names. - A reader who sends no
@contextsees full IRIs for every name that only the writer's@contextdefined. - A query is expanded too.
type=Vehiclewith no@contextasks forhttps://uri.etsi.org/ngsi-ld/default-context/Vehicle, which matches nothing that was written ashttps://example.org/vehicle/Vehicle.
So decide on one @context and send it on every request, writes, reads, queries and subscriptions alike.
The examples below create Vehicle A4567, the car from the specification's own examples, under this @context.
{
"Vehicle": "https://example.org/vehicle/Vehicle",
"brandName": "https://example.org/vehicle/brandName"
}
Sending a @context in the body
The first way to send a @context is the JSON-LD way, putting it in the body as an @context member and saying so with Content-Type: application/ld+json.
- http
- curl
- rust
- python
POST /ngsi-ld/v1/entities
Content-Type: application/ld+json
{
"id": "urn:ngsi-ld:Vehicle:A4567",
"type": "Vehicle",
"brandName": {
"type": "Property",
"value": "Mercedes"
},
"@context": {
"Vehicle": "https://example.org/vehicle/Vehicle",
"brandName": "https://example.org/vehicle/brandName"
}
}
curl -i -X POST http://localhost:1056/ngsi-ld/v1/entities \
-H 'Content-Type: application/ld+json' \
-d '{
"id": "urn:ngsi-ld:Vehicle:A4567",
"type": "Vehicle",
"brandName": {
"type": "Property",
"value": "Mercedes"
},
"@context": {
"Vehicle": "https://example.org/vehicle/Vehicle",
"brandName": "https://example.org/vehicle/brandName"
}
}'
let context = Context::Single(ContextEntry::Inline(
r#"{"Vehicle": "https://example.org/vehicle/Vehicle", "brandName": "https://example.org/vehicle/brandName"}"#
.parse::<InlineContext>()?,
));
let vehicle = Entity::builder()
.id(iri!("urn:ngsi-ld:Vehicle:A4567"))
.entity_type("Vehicle")
.property("brandName", "Mercedes")
.build()?;
let outcome = client
.with_context(context)?
.with_context_delivery(ContextDelivery::LdJsonBody)
.entities()
.create(&vehicle)
.send()
.await?;
response = requests.post(
f"{BASE}/entities",
json={
"id": "urn:ngsi-ld:Vehicle:A4567",
"type": "Vehicle",
"brandName": {
"type": "Property",
"value": "Mercedes",
},
"@context": {
"Vehicle": "https://example.org/vehicle/Vehicle",
"brandName": "https://example.org/vehicle/brandName",
},
},
headers={"Content-Type": "application/ld+json"},
)
assert response.status_code == 201
HTTP/1.1 201 Created
Location: /ngsi-ld/v1/entities/urn:ngsi-ld:Vehicle:A4567
The body form takes any @context, whether a URL, an inline object as here, or an array of both. Inline is handy for trying things out, but the specification recommends against it for real use. A broker caches and shares a @context by its URL, and an inline one has none, so every client has to carry its own copy in every request. Publish your @context at a URL instead, on your own server or on the broker itself.
Storing a @context on the broker
The broker can host your @context for you, so you don't need a web server just to publish one. Send the document, an object with a top-level @context, to /ngsi-ld/v1/jsonldContexts.
- http
- curl
- rust
- python
POST /ngsi-ld/v1/jsonldContexts
Content-Type: application/json
{
"@context": {
"Vehicle": "https://example.org/vehicle/Vehicle",
"brandName": "https://example.org/vehicle/brandName"
}
}
curl -i -X POST http://localhost:1056/ngsi-ld/v1/jsonldContexts \
-H 'Content-Type: application/json' \
-d '{
"@context": {
"Vehicle": "https://example.org/vehicle/Vehicle",
"brandName": "https://example.org/vehicle/brandName"
}
}'
let context = Context::Single(ContextEntry::Inline(
r#"{"Vehicle": "https://example.org/vehicle/Vehicle", "brandName": "https://example.org/vehicle/brandName"}"#
.parse::<InlineContext>()?,
));
let stored = client.contexts().add(&context).send().await?;
let id = stored.id().ok_or("the broker named no @context")?;
response = requests.post(
f"{BASE}/jsonldContexts",
json={
"@context": {
"Vehicle": "https://example.org/vehicle/Vehicle",
"brandName": "https://example.org/vehicle/brandName",
},
},
)
assert response.status_code == 201
location = response.headers["Location"]
HTTP/1.1 201 Created
Location: /ngsi-ld/v1/jsonldContexts/urn:ngsi-ld:JsonLdContext:2fc7f63e-9213-4821-8720-65b9a404df6f
The broker gives the document an identifier of its own, and the Location is where it now serves it. Yours will have a different identifier. Prefix the Location with the broker's address and you have the URL to use from now on.
http://localhost:1056/ngsi-ld/v1/jsonldContexts/urn:ngsi-ld:JsonLdContext:2fc7f63e-9213-4821-8720-65b9a404df6f
Any member beside @context in the body you send is discarded, as JSON-LD requires. A body without a top-level @context is refused with 400 BadRequestData, and nothing is stored.
A GET on that URL answers the document as you stored it. To see everything you have stored, list the Hosted entries.
- http
- curl
- rust
- python
GET /ngsi-ld/v1/jsonldContexts?kind=Hosted
Accept: application/json
curl -i 'http://localhost:1056/ngsi-ld/v1/jsonldContexts?kind=Hosted' \
-H 'Accept: application/json'
let urls = client
.contexts()
.list()
.kind(ContextKindFilter::Hosted)
.send()
.await?;
response = requests.get(
f"{BASE}/jsonldContexts",
params={"kind": "Hosted"},
headers={"Accept": "application/json"},
)
assert response.status_code == 200
HTTP/1.1 200 OK
Content-Type: application/json
[
"http://localhost:1056/ngsi-ld/v1/jsonldContexts/urn:ngsi-ld:JsonLdContext:2fc7f63e-9213-4821-8720-65b9a404df6f"
]
Hosted is one of three kinds. The broker also keeps Cached copies of the remote @contexts it has downloaded, and ImplicitlyCreated documents it made itself, for instance to give an array of contexts a single URL. Leave out kind to list them all, and add details=true to get each entry's identifier, kind, creation time and usage counts instead of the bare URL.
Stored @contexts belong to a tenant, although the URL doesn't name one. A request for another tenant doesn't find it, so send the same NGSILD-Tenant header you stored it with.
When you no longer need one, delete it by its identifier.
- http
- curl
- rust
- python
DELETE /ngsi-ld/v1/jsonldContexts/urn:ngsi-ld:JsonLdContext:2fc7f63e-9213-4821-8720-65b9a404df6f
curl -i -X DELETE http://localhost:1056/ngsi-ld/v1/jsonldContexts/urn:ngsi-ld:JsonLdContext:2fc7f63e-9213-4821-8720-65b9a404df6f
client.contexts().delete(&id).send().await?;
response = requests.delete(
f"{BASE}/jsonldContexts/urn:ngsi-ld:JsonLdContext:2fc7f63e-9213-4821-8720-65b9a404df6f"
)
assert response.status_code == 204
HTTP/1.1 204 No Content
Deleting the document changes nothing that was written with it. Entities hold IRIs, not the @context that produced them.
Sending a @context in a Link header
With the @context at a URL, you can keep it out of the body altogether. Send the body as plain application/json and name the @context in a Link header.
Link: <http://localhost:1056/ngsi-ld/v1/jsonldContexts/urn:ngsi-ld:JsonLdContext:2fc7f63e-9213-4821-8720-65b9a404df6f>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
The URL goes between the angle brackets. The rel value is fixed. It is what marks this link as a JSON-LD @context rather than any other kind of link. Here is the same vehicle created that way.
- http
- curl
- rust
- python
POST /ngsi-ld/v1/entities
Content-Type: application/json
Link: <http://localhost:1056/ngsi-ld/v1/jsonldContexts/urn:ngsi-ld:JsonLdContext:2fc7f63e-9213-4821-8720-65b9a404df6f>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
{
"id": "urn:ngsi-ld:Vehicle:A4567",
"type": "Vehicle",
"brandName": {
"type": "Property",
"value": "Mercedes"
}
}
curl -i -X POST http://localhost:1056/ngsi-ld/v1/entities \
-H 'Content-Type: application/json' \
-H 'Link: <http://localhost:1056/ngsi-ld/v1/jsonldContexts/urn:ngsi-ld:JsonLdContext:2fc7f63e-9213-4821-8720-65b9a404df6f>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"' \
-d '{
"id": "urn:ngsi-ld:Vehicle:A4567",
"type": "Vehicle",
"brandName": {
"type": "Property",
"value": "Mercedes"
}
}'
let linked = client.with_context(
iri!("http://localhost:1056/ngsi-ld/v1/jsonldContexts/urn:ngsi-ld:JsonLdContext:2fc7f63e-9213-4821-8720-65b9a404df6f")
.to_owned(),
)?;
let outcome = linked.entities().create(&vehicle).send().await?;
CONTEXT = "http://localhost:1056/ngsi-ld/v1/jsonldContexts/urn:ngsi-ld:JsonLdContext:2fc7f63e-9213-4821-8720-65b9a404df6f"
LINK = f'<{CONTEXT}>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"'
response = requests.post(
f"{BASE}/entities",
json={
"id": "urn:ngsi-ld:Vehicle:A4567",
"type": "Vehicle",
"brandName": {
"type": "Property",
"value": "Mercedes",
},
},
headers={"Link": LINK},
)
assert response.status_code == 201
The Rust client derived with with_context sends the Link on every request it makes, and the Python examples below pass LINK the same way.
The Link header is also the only way to give a @context to a request that has no body, which is every GET and DELETE, including every query. So even if you write with @context in the body, your reads use the header.
Which form a request uses is decided by its method and Content-Type, and the two are never mixed.
| Request | Where the @context goes |
|---|---|
GET, DELETE | Link header |
POST, PATCH, PUT with application/json | Link header, and the body must not hold @context |
POST, PATCH, PUT with application/ld+json | @context member of the body, and no Link header |
A request with neither gets the core @context alone, so every name it uses falls into the default namespace. A Link header names one URL, which is why an inline @context or an array has to travel in an application/ld+json body, or be stored first so that it has a URL of its own.
Reading data back
Read the vehicle with the stored @context and ask for application/ld+json, and the answer carries its @context in the body.
- http
- curl
- rust
- python
GET /ngsi-ld/v1/entities/urn:ngsi-ld:Vehicle:A4567
Accept: application/ld+json
Link: <http://localhost:1056/ngsi-ld/v1/jsonldContexts/urn:ngsi-ld:JsonLdContext:2fc7f63e-9213-4821-8720-65b9a404df6f>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
curl -i http://localhost:1056/ngsi-ld/v1/entities/urn:ngsi-ld:Vehicle:A4567 \
-H 'Accept: application/ld+json' \
-H 'Link: <http://localhost:1056/ngsi-ld/v1/jsonldContexts/urn:ngsi-ld:JsonLdContext:2fc7f63e-9213-4821-8720-65b9a404df6f>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"'
let vehicle = EntityId::from(iri!("urn:ngsi-ld:Vehicle:A4567"));
let retrieved = linked
.with_accept(Accept::new(NgsiLdMediaType::JsonLd))
.entities()
.retrieve(&vehicle)
.send()
.await?;
response = requests.get(
f"{BASE}/entities/urn:ngsi-ld:Vehicle:A4567",
headers={"Accept": "application/ld+json", "Link": LINK},
)
assert response.status_code == 200
HTTP/1.1 200 OK
Content-Type: application/ld+json
{
"id": "urn:ngsi-ld:Vehicle:A4567",
"type": "Vehicle",
"brandName": {
"type": "Property",
"value": "Mercedes"
},
"@context": [
"http://localhost:1056/ngsi-ld/v1/jsonldContexts/urn:ngsi-ld:JsonLdContext:2fc7f63e-9213-4821-8720-65b9a404df6f",
"https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld"
]
}
The answer names your @context first and the core @context last, so the document stands on its own, and any JSON-LD processor that reads it expands it to the same IRIs the broker stored. With Accept: application/json the body is the same without the @context member, and the @context is named in a Link header of the answer instead.
Now read the same vehicle without a Link header. The core @context alone can't compact the vehicle's own IRIs, so they come back in full.
GET /ngsi-ld/v1/entities/urn:ngsi-ld:Vehicle:A4567
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": "urn:ngsi-ld:Vehicle:A4567",
"type": "https://example.org/vehicle/Vehicle",
"https://example.org/vehicle/brandName": {
"type": "Property",
"value": "Mercedes"
}
}
Nothing is wrong with the entity. It is the same data, written without the short names that only your @context knows. NGSI-LD's own terms, id, type and value, still read short, because the core @context always applies.
The same thing happens the other way round. A vehicle written with no @context at all is stored under the default namespace, as https://uri.etsi.org/ngsi-ld/default-context/brandName. Read without a @context, it looks fine, because the default namespace compacts back to brandName. Read with the @context above, which gives brandName a different IRI, it comes back as ngsi-ld:default-context/brandName, and its type as ngsi-ld:default-context/Vehicle. If you see names like these, someone wrote without the @context you read with.
Queries follow the same rule, because type, attrs, q and the other parameters that name terms are expanded with the request's @context. GET /ngsi-ld/v1/entities?type=Vehicle without the Link header answers [], because it asks for the default namespace's Vehicle. Subscriptions do too, and each one keeps a jsonldContext, the @context its notifications are compacted with. Subscriptions shows how to set it.
Writing a good @context
- Use IRIs you control, or terms from a published vocabulary such as the Smart Data Models. An IRI under
example.orgis fine for trying things out and nothing else. - Define every entity type and every attribute name you use. A name you leave out falls into the default namespace, where anyone else's undefined
brandNamemeans the same as yours. - Never change what a published
@contextmaps a term to. Data already written keeps the old IRI, so the same short name would then mean two things. Publish a new version under a new URL instead. - Don't try to redefine core terms. The core
@contextis protected, sotype,valueand the rest keep their NGSI-LD meaning whatever your@contextsays. - Share one
@contextacross every client that reads and writes the same data, and send it on every request.
What gets refused
400BadRequestDatafor anapplication/jsonbody that holds@context.400BadRequestDatafor anapplication/ld+jsonbody without@context, or one sent with aLinkheader naming a@context.400BadRequestDatafor more than one@contextinLinkheaders.400BadRequestDatafor a@contextstored without a top-level@contextmember.504LdContextNotAvailablefor a@contextURL the broker can't fetch.
JSON-LD @context in Strictness lists every rule a @context has to follow.