Exporting JSON
Exporting JSON differs from a raw JSON export as it implies selecting and remapping source data into a different JSON schema. So here we are effectively transforming source data into any other schema, as required by the consumer.
A typical application of this export process is a frontend designed around a set of predefined view models, specifically designed for it. Data is thus transformed into the target schema, similarly to what happens in XML via XSLT.
This export process shares its source data selection and mapping logic with the graph exporter, which targets RDF, with an important difference:
- the graph exporter is designed to constantly map items into an RDF graph, keeping it in synch with user edits. Whenever users save their edits in the editor, data gets selected and mapped to target the graph according to the chosen ontologies. Users can even edit the target RDF graph directly to manually extend it.
- the JSON exporter instead is not designed to export data in real-time. Rather, its purpose is to provide a massive JSON-based data transform and export, to be later consumed by a third-party client: for instance, a view-model for a web UI based on exported data. So, the task of the JSON exporter is only to generate JSON documents, without caring about their usage. The graph exporter instead targets a graph database actively updating it to keep the graph in synch with user edits. Then, at any moment one can also export the nodes and triples of this graph into various standard formats to consume them as required.
While this difference applies to the output of each process, the logic to select the items and parts to export and map them into a different target is shared for these tasks, covered by mappings:
- collecting source items to export.
- apply loaded mappings to project data from each source object’s property node to the designed target.
This page is a practical guide for people who have to write these mappings. Rather than just describing each property in isolation, it walks through the exact algorithm the mapper follows, so that you can predict what a mapping will produce before running it, and quickly diagnose it when it does not produce what you expected.
Configuration
The export configuration is defined in a JSON document like this (see mappings configuration for the details about mappings):
{
"source": {
"itemIdCollector": {
"id": "it.vedph.item-id-collector.mongo",
"options": {
"pageNumber": 1,
"pageSize": 20,
"title": "...",
"description": "...",
"facetId": "...",
"groupId": "...",
"flags": null,
"flagMatching": 0
}
},
"itemJsonReader": {
"id": "item-json-reader.mongo"
},
"partFilter": {
"isInverted": false,
"clauses": [
{
"typeId": "...",
"roleId": "..."
}
]
},
"templateFilters": [
/* array of configurable objects, each with a "keys" property */
]
},
"namedMappings": {
/* ... */
},
"mappings": [
/* ... */
]
}
Each entry in templateFilters registers a Fluid filter (see custom filters below) so it becomes usable from your mapping output templates. It must specify:
id: the filter component’s tag (e.g.fluid-filter.historical-date).keys: the Fluid filter keyword(s) it is invoked with in a template (e.g.historical-date, used as{{ value | historical-date }}).options: any options the filter requires (e.g. a connection string).
⚠️ If you omit
keys, the filter is silently never registered. There is no error either at load time or when the template later references it: Fluid treats an unknown filter name as a no-op passthrough, not an error. So a missingkeystypically shows up much later, as a value that just did not get transformed the way you expected.
How Mapping Works: A Mental Model
Each NodeMapping (see abstract node mapping) matches a source node, and a JsonNodeMapping additionally renders an output template for it. Understanding exactly what happens between these two steps is the key to writing mappings that work on the first try. The algorithm, for each mapping, is:
- Select.
sourceis a JMES path evaluated against the mapping’s current context: for a root mapping, that is the whole source object (item + parts); for a child mapping, it is whatever node its parent mapping matched (see children mappings below). The special value.means “the current context as a whole”. - React to what was selected, one of four cases:
- nothing (the path does not exist, or is JSON
null): the mapping (and all its children) produce no output at all, silently. This is not an error, so a wrongsourcenever throws — it just quietly leaves a hole in your output. See pitfall #1. - an array: the mapper automatically loops over its items. The mapping (and its children) run once per item, and for that run the item itself becomes the new current context — you never see the raw array. A per-iteration
indexmetadatum (0-based) is also available. See array recipes. - a scalar (string, number, boolean): it is exposed as a raw string, regardless of its original JSON type. This is deliberate (so a string that happens to look like a number is not silently parsed as one), but it means numbers and booleans need care — see pitfall #3.
- an object: it is exposed as itself, with its own properties reachable by name.
- nothing (the path does not exist, or is JSON
- Render. For an object or a scalar (not an array, which was already consumed by the loop in step 2), the
outputFluid template is rendered with:value: exactly what was selected in step 1/2 — nothing more, nothing less. Fluid never “reaches past” yoursourceexpression for you: ifsourceselects a wrapper object, its inner fields are one level deeper than you might expect. See pitfall #2.metadata: the mapper’s metadata dictionary (includesindexwhile iterating an array, plus anything you or a macro added).sid: the SID resolved for this mapping, if any. The rendered text must be valid JSON on its own (a full object, or a full array). Because Fluid does not encode its output for JSON by default, always pass values through the built-injsonfilter before embedding them (e.g.{{ value.name | json }}), except where you deliberately want a raw, already-valid JSON literal (as when wrapping a whole selected object in[ ... ], see the array recipes).
- Merge. The rendered JSON is merged into the shared target object, either at its root (when
targetPropertyis empty — in which case the rendered JSON must itself be an object) or undertargetProperty(which can itself use the placeholders below). Merging follows simple, consistent rules, applied every time a mapping’s output lands on the same property (whether because several sibling mappings target it, or because an array made the same mapping run more than once):- object + object → deep merge (existing properties enriched or overridden by the new ones);
- array + array → concatenate (new items appended);
- anything else → the new value replaces the old one.
- Recurse. If the mapping has
children, each of them is matched again, but against the node selected in step 1 — not against the root. This is what lets you decompose a complex path into small, single-purpose mappings instead of one giant template (see nested children).
Steps 1–5 repeat, independently, for every mapping in your configuration (and every array item that triggers step 2’s loop). There is no shared mutable “cursor” across mappings: each one starts fresh from its own source.
Placeholders in sid and targetProperty
targetProperty (and, on any mapping, sid) is not rendered by Fluid — it goes through a small placeholder resolver supporting three forms, which can be mixed with literal text:
| Syntax | Meaning | Example |
|---|---|---|
{$name} | a metadatum: looks up name in the mapper’s Data/metadata dictionary | {$slot} → the value of mapper.Data["slot"] |
{@expr} | a data expression: a JMES path evaluated against the node currently selected by source (. for the whole node) | {@type} → the type property of the current node |
{!name(args)} | a macro: a registered INodeMappingMacro, with &-separated arguments | {!_hdate(...)} |
For instance, a mapping whose source yields items with a type field can target a differently-named property per item without writing one mapping per type:
{
"source": "events[]",
"output": "{ \"year\": {{ value.year | json }} }",
"targetProperty": "{@type}"
}
Note the different syntax families:
source/outputuse JMES path and Fluid respectively, whilesid/targetPropertyuse this placeholder mini-language. Mixing them up (e.g. writing a JMES path directly intargetProperty) is a common source of confusion —targetPropertyonly understands{$...},{@...}, and{!...}.
Cookbook
All the examples below use this small source object unless otherwise noted:
{ "name": "Jane", "surname": "Doe" }
1. Copy a scalar property, renamed
{
"source": "name",
"output": "{{ value | json }}",
"targetProperty": "fullName"
}
→ { "fullName": "Jane" }. value here is the raw string "Jane" (see step 2’s scalar case); the json filter quotes it as a JSON string, which is what you want for an actual string.
2. Copy an object as-is, at the root
{
"source": ".",
"output": "{ \"greeting\": \"Hello, {{ value.name }}!\" }"
}
→ { "greeting": "Hello, Jane!" }, merged directly at the root because targetProperty is empty. This only works because the rendered output is itself an object (rule in step 4); if it were e.g. a bare string, the mapper would throw InvalidOperationException rather than guess where to put it.
3. Combine sibling mappings into the same target object
Two independent mappings can enrich the same object, thanks to the object+object deep-merge rule (step 4):
[
{
"source": "name",
"output": "{ \"person\": {\"firstName\": {{ value | json }} } }"
},
{
"source": "surname",
"output": "{ \"person\": {\"lastName\": {{ value | json }} } }"
}
]
→ { "person": { "firstName": "Jane", "lastName": "Doe" } }.
4. Build a nested object from several source fields
When several output fields come from the same source node, prefer a single mapping selecting the common ancestor, rather than several siblings:
{
"source": ".",
"output": "{ \"address\": { \"street\": \"{{ value.street }}\", \"city\": \"{{ value.city }}\" } }"
}
Given source:
{
"street": "123 Main St",
"city": "Anytown"
}
The output is:
{ "address": {
"street": "123 Main St",
"city": "Anytown"
}
}
5. Map an array of scalars into an array
Given { "tags": ["a", "b"] }:
{
"source": "tags",
"output": "[{{ value | json }}]",
"targetProperty": "tags"
}
Here source selects the whole tags array, so per step 2 the mapper loops: the mapping runs twice, once per item, each time with value bound to a single tag string. Each run’s output is a one-item array (["a"], then ["b"]); per step 4’s array+array rule these get concatenated into the final ["a", "b"]. This is why the template wraps a single item in [ ... ] rather than trying to render the whole array at once — by the time your template runs, the array is already gone, replaced by one of its items.
6. Map an array of objects, reshaping each item
Given { "events": [ { "type": "birth", "year": 1265 }, { "type": "death", "year": 1321 } ] }:
{
"source": "events",
"output": "[ { \"type\": {{ value.type | json }} } ]",
"targetProperty": "events"
}
→ { "events": [ { "type": "birth" }, { "type": "death" } ] }. Same looping/concatenation mechanism as above, but each item is an object so you can pick a subset of its properties (here, dropping year).
7. Pick a single item from an array
Indexing collapses the array back to a single node, so the mapper does not loop, and value is bound directly to that one item (an object, not an array):
{
"source": "events[0]",
"output": "{ \"firstEventType\": {{ value.type | json }} }"
}
→ { "firstEventType": "birth" }.
8. Numbers and booleans: getting real JSON types out
Given { "age": 42 }, this looks reasonable but is wrong:
{
"source": "age",
"output": "{ \"age\": {{ value | json }} }"
}
→ { "age": "42" } — a quoted string, not a number. This is pitfall #3: a directly-selected scalar is always a raw .NET string (step 2), and the json filter always JSON-encodes whatever it is given as a string when given a string, so a raw numeric-looking string becomes a quoted string. There are two correct fixes:
-
select the parent object instead, and reach the field as a Fluid member access — this way the value passes through normal JSON parsing (which does preserve its type) rather than the scalar-string shortcut:
{ "source": ".", "output": "{ \"age\": {{ value.age | json }} }" }→
{ "age": 42 }, correctly numeric. -
or, if you really must select the scalar directly, drop the
jsonfilter and interpolate it bare:{ "age": {{ value }} }also produces{ "age": 42 }, because the raw text42is already valid (unquoted) JSON. This only works for numbers/booleans/null— never do this for a string value, since the result would not be valid JSON ({ "name": Jane }).
9. Nested (children) mappings
Instead of one large output reaching deep into a structure, split it into a parent mapping that narrows the context, and children that fill in the details (step 5). Given { "person": { "name": "Jane", "age": 42 } }:
{
"source": "person",
"children": [
{
"source": ".",
"output": "{ \"fullName\": {{ value.name | json }} }"
},
{
"source": ".",
"output": "{ \"age\": {{ value.age | json }} }"
}
]
}
Each child’s source (.) is evaluated against the parent’s selected node (person), not the document root — this is the mechanism that lets deeply nested schemas be expressed as a shallow tree of small, single-purpose mappings, mirroring the structure of the source data itself.
Custom Filters
JsonTemplateNodeMapper.Filters is preloaded with Fluid’s standard filters, including the json filter used throughout this page. Two Cadmus-specific ones ship with the exporter, both registered by tag under a keys entry in source.templateFilters (see Configuration):
-
fluid-filter.historical-date(typically keyed ashistorical-date): parses its input as aHistoricalDate(either a string like"123 AD", or the JSON shape produced by serializing one) and returns either its numeric sort value (default), or, with a"text"argument, its textual form:"output": "{ \"when\": {{ value | historical-date: 'text' | json }} }" -
fluid-filter.mongo-thesaurus(typically keyed asmongo-thesaurus, requires aconnectionStringoption): scans its string input for$[thesaurusId|entryId]references and replaces each with the corresponding thesaurus entry’s value, looked up in MongoDB (falling back through@eng/@en, and following alias thesauri). It leaves anything that is not a match untouched, so you typically build the reference around your raw code first:"output": "[{{ value | prepend: '$[categories_ins-fn@en|' | append: ']' | mongo-thesaurus | json }}]"Given a source category code
function:buildingand a thesauruscategories_ins-fn@enwith an entryfunction:building→building, this produces["building"].
You can also register your own filters in code, before calling Map:
mapper.Filters.AddFilter("shout", (input, _, _) =>
new ValueTask<FluidValue>(new StringValue(input.ToStringValue().ToUpperInvariant())));
A misspelled or unregistered filter name is not an error. Fluid treats it as a no-op: {{ value | nope | json }} on "Jane" silently renders "Jane", exactly as if | nope had not been written at all — no exception, no warning, at parse time or at render time. This is pitfall #4 below.
Troubleshooting Checklist
These are, in order, the mistakes most likely to make a mapping produce the wrong thing while looking perfectly reasonable — each one drawn from an actual failure, not a hypothetical.
Pitfall #1: a wrong path fails silently, not loudly
Per step 2, a source that matches nothing (or null) makes the mapping produce no output, without throwing. A null/missing field in your result is the mapper telling you “I found nothing here” — it is not a bug to work around downstream, it is a source (or a member access inside output) to fix. Before trusting an output template, verify what source actually selects, independently: paste your source JSON and the JMES path into an online evaluator (or a small unit test asserting on value’s shape) and confirm you get what you expect, before writing the template around it.
Pitfall #2: an extra value wrapper
Many Cadmus part models wrap their payload in an extra layer, e.g. a value property (an asserted location entry is { "tag": ..., "value": { "latitude": ..., "longitude": ... }, "assertion": ... }, not { "latitude": ..., "longitude": ... } directly).
Since value in your template is exactly what source selected, this means an extra .value hop is often needed: value.value.latitude, not value.latitude. There is no shortcut here other than checking the actual JSON shape of whatever source selects — different parts nest differently, and assuming they are all flat is the single most common mistake when writing a new mapping.
Pitfall #3: a number or boolean quoted as a string
Covered in recipe #8: a directly-selected scalar is always a raw string, so piping it through json always yields a quoted string, even for a source number or boolean.
Pitfall #4: an unknown filter is a silent no-op
Covered in custom filters: a typo in a filter name, or forgetting to list it under keys in source.templateFilters, does not raise any error — the filter name is simply skipped, and the value flows through unchanged. If a historical-date- or thesaurus-resolved value looks suspiciously like the raw, untransformed source value, this is the first thing to check.
Pitfall #5: . is not a valid Fluid expression
source and targetProperty both accept . to mean “the current node”, but output is Fluid, which has no such shorthand — {{ . | json }} fails to parse (A value was expected). Always start a Fluid expression from value, metadata, or sid.
Pitfall #6: mismatched targetProperty types across sibling/iteration outputs
Step 4’s merge rules are forgiving between object+object and array+array, but anything else replaces rather than merges. If one mapping (or one array iteration) unexpectedly produces a scalar or a differently-shaped object under a targetProperty that another mapping also targets, the second one silently overwrites the first rather than erroring — keep the shape of a given targetProperty consistent across every mapping (and every array iteration) that can write to it.
Complete Worked Example
Here is a realistic example using a single item with its parts as source. To get such a JSON input you can use the JSON dump command in the CLI tool. The generated JSON code includes:
- item’s metadata.
- an additional
_statusproperty (this is used in JSON dump). - an additional
_partsproperty which is an array including all the parts in the item.
Note that each part object includes its metadata and then a
contentproperty with its data, plus a copy of the part’s metadata. This small redundancy is by design since the first versions of Cadmus, as it allows storage scenarios where part metadata are separated from content.
{
"_id": "80b23b04-625d-4a7c-8cfa-d512034af643",
"title": "ISic000822",
"description": "Insediamento con necropoli nei Monti Iblei, documentato tra il VII e l'inizio del V secolo a.C.",
"facetId": "inscription",
"groupId": null,
"sortKey": "isic000822",
"flags": 0,
"timeCreated": { "$date": "2026-08-30T16:41:07.026Z" },
"creatorId": "zeus",
"timeModified": { "$date": "2026-08-30T16:41:07.027Z" },
"userId": "zeus",
"_status": 0,
"_parts": [
{
"_id": "83a128de-56ea-4ec5-a812-3e9c3f68797a",
"itemId": "80b23b04-625d-4a7c-8cfa-d512034af643",
"typeId": "it.vedph.asserted-historical-dates",
"roleId": null,
"content": {
"dates": [
{
"tag": null,
"assertion": null,
"a": {
"value": -600,
"isCentury": false,
"isSpan": false,
"isApproximate": false,
"isDubious": false,
"day": 0,
"month": 0,
"hint": null,
"slide": 0
},
"b": {
"value": -551,
"isCentury": false,
"isSpan": false,
"isApproximate": false,
"isDubious": false,
"day": 0,
"month": 0,
"hint": null,
"slide": 0
}
}
]
}
},
{
"_id": "8ffcd758-c2b0-4350-b27f-dad6f57e4042",
"itemId": "80b23b04-625d-4a7c-8cfa-d512034af643",
"typeId": "it.vedph.geo.asserted-locations",
"roleId": null,
"content": {
"locations": [
{
"tag": "origin",
"value": {
"eid": null,
"label": "Syracusae",
"latitude": 37.08415,
"longitude": 15.27628,
"altitude": null,
"radius": null,
"geometry": null,
"note": null
},
"assertion": null
}
]
}
},
{
"_id": "dc9e1d49-aa8a-4f1a-9a88-a90da11b8784",
"itemId": "80b23b04-625d-4a7c-8cfa-d512034af643",
"typeId": "it.vedph.categories",
"roleId": "ins-fn",
"content": {
"categories": ["function:building", "function:dedication"]
}
}
]
}
This is trimmed for readability: the full dump also repeats each part’s metadata inside
content, and includes several more parts (technique, support, text, language category, Pleiades links…). The Item.json test asset has the complete, untrimmed version, and every mapping below is verified against it.
The item refers to an inscription; the parts relevant to this example are:
it.vedph.asserted-historical-dates: date (600-551 BC).it.vedph.geo.asserted-locations: geographical coordinates (37.08415, 15.27628 for Syracusae).it.vedph.categories:ins-fnlisting the inscription’s functions (function:building,function:dedication).
Let us imagine that we want to select and transform a subset of these data into this JSON output:
{
"title": "ISic000822",
"summary": "Insediamento con necropoli nei Monti Iblei, documentato tra il VII e l'inizio del V secolo a.C.",
"functions": ["function:building", "function:dedication"],
"location": {
"latitude": 37.08415,
"longitude": 15.27628,
"altitude": null,
"certainty": null
},
"date": [
{
"text": "600 -- 551 BC",
"scope": "tes"
}
]
}
Here is the corresponding export configuration:
{
"source": {
"itemIdCollector": {
"id": "it.vedph.item-id-collector.mongo",
"options": {
"facetId": "inscription"
}
},
"itemJsonReader": {
"id": "item-json-reader.mongo"
},
"templateFilters": [
{
"id": "fluid-filter.historical-date",
"keys": ["historical-date"]
}
]
},
"mappings": [
{
"description": "title => title",
"source": "title",
"output": "{{ value | json }}",
"targetProperty": "title"
},
{
"description": "description => summary",
"source": "description",
"output": "{{ value | json }}",
"targetProperty": "summary"
},
{
"description": "categories:ins-fn codes => functions",
"source": "_parts[?typeId=='it.vedph.categories' && roleId=='ins-fn'].content.categories",
"output": "[{{ value | json }}]",
"targetProperty": "functions"
},
{
"description": "locations[0] => location.{latitude,longitude,altitude,certainty}",
"source": "_parts[?typeId=='it.vedph.geo.asserted-locations'].content.locations[0]",
"output": "{ \"latitude\": {{ value.value.latitude | json }}, \"longitude\": {{ value.value.longitude | json }}, \"altitude\": {{ value.value.altitude | json }}, \"certainty\": {{ value.assertion.rank | json }} }",
"targetProperty": "location"
},
{
"description": "asserted-historical-dates dates[0] => date[0].{text,scope}",
"source": "_parts[?typeId=='it.vedph.asserted-historical-dates'].content.dates[0]",
"output": "[{ \"text\": {{ value | historical-date: 'text' | json }}, \"scope\": \"tes\" }]",
"targetProperty": "date"
}
]
}
A few things worth pointing out about this configuration, tying back to the model above:
- the
locationmapping needs the extravalue.value.*hop (pitfall #2), becauselocations[0]selects{ "tag", "value": { "latitude", ... }, "assertion" }, not the coordinates directly; andvalue.assertion.rankrather than a bareassertion.rank, sinceassertionis only reachable as a member ofvalue, never as a top-level Fluid variable on its own. - the
functionsmapping just maps the identifiers of each thesaurus entry. In most cases this is what the consumer requires, because identifiers are the real data and their human-friendly, language-dependent labels vary according to your purpose. Anyway, Cadmus provides a lookup filter should you want to directly get the label from the ID of any thesaurus entry. assertionisnullin the source, socertaintycorrectly comes out asnulltoo — a missing/null member access does not throw, it just propagates asnull(do not confuse this with pitfall #1, which is aboutsourceitself matching nothing: heresourcedoes match, and it is a specific member of the matched node that is absent).- the
datemapping’shistorical-datefilter is only usable because it was registered underkeys: ["historical-date"]intemplateFilters— dropping that line would silently turn{{ value | historical-date: 'text' | json }}into{{ value | json }}(pitfall #4), rendering the raw{ "a": {...}, "b": {...} }structure instead of"600 -- 551 BC". - both the
functionsanddatemappings wrap a single rendered object in[ ... ], even though theirsourcedoes not end in[0]forfunctions(it loops instead — see recipe #5) and does fordate(it does not loop — see recipe #7); either way, the goal is an array undertargetProperty, so the template must render one.
Executing an Export
To execute a JSON export for data, use this command in the Cadmus CLI tool: