Export Template Guide¶
Stored export templates format stored export output from the async export API.
Use executable export templates when you want text/plain, text/html, or text/csv output from a stored definition in POST /api/v1/export-templates.
Run the template with POST /api/v1/export-templates/{template_id}/exports, then fetch the rendered result from
GET /api/v1/exports/{task_id}/output.
See also:
- export_api.md for export execution semantics
- permissions.md for template permissions
What a template receives¶
Templates render against a context object with these top-level keys:
itemsmetawarningsrequestsource- present for templated
related_objectsexports and points at the hydrated root object
Rather than start with the full context object, it is usually easier to think in terms of classes and objects.
Example classes and objects¶
Assume you have a class called server with objects like these:
[
{
"id": 101,
"name": "srv-app-01",
"description": "Application server",
"collection_id": 7,
"hubuum_class_id": 42,
"data": {
"owner": "alice",
"hostname": "srv-app-01.example.org",
"environment": "prod",
"tags": ["prod", "app"]
}
},
{
"id": 102,
"name": "srv-db-01",
"description": "Database server",
"collection_id": 7,
"hubuum_class_id": 42,
"data": {
"owner": "bob",
"hostname": "srv-db-01.example.org",
"environment": "prod",
"tags": ["prod", "db"]
}
}
]
If you create an executable template for that class:
{
"collection_id": 7,
"name": "export.servers",
"description": "Server owner export",
"content_type": "text/plain",
"template": "{% for item in items %}{{ item.name }}={{ item.data.owner }}\n{% endfor %}",
"kind": "export",
"scope_kind": "objects_in_class",
"class_id": 42,
"default_query": "name__contains=srv-&sort=name",
"default_missing_data_policy": "strict",
"default_limits": {
"max_items": 100,
"max_output_bytes": 262144
}
}
and run it:
then items contains the matching objects, so export templates can reference fields like:
{{ item.name }}{{ item.description }}{{ item.data.owner }}{{ item.data.hostname }}{{ item.data.environment }}
The other top-level values are still available when you need them.
Example:
{
"meta": {
"count": 2,
"truncated": false,
"scope": {
"kind": "objects_in_class",
"class_id": 42,
"object_id": null
},
"content_type": "text/plain"
},
"warnings": [],
"request": {
"scope": {
"kind": "objects_in_class",
"class_id": 42,
"object_id": null
},
"query": "name__contains=srv-&sort=name"
}
}
Template syntax¶
Stored export templates use Jinja syntax. See the MiniJinja template syntax documentation for the full expression and tag reference.
Common features:
{{ meta.count }}interpolates a value{% for item in items %}...{% endfor %}iterates arrays{% if ... %}...{% endif %}handles conditionals{% include "name" %},{% import "name" as macros %}, and{% extends "name" %}resolve export_templates by name within the same collection- normal Jinja expressions, filters,
set, and macros are supported
Examples:
{{ meta.count }}
{{ request.scope.kind }}
{% for item in items %}{{ item.name }}
{% endfor %}
{% for item in items %}{% for tag in item.data.tags %}- {{ tag }}
{% endfor %}{% endfor %}
MiniJinja operators and expressions¶
Templates use the standard MiniJinja expression language unless otherwise noted below.
Common operators:
- comparisons:
==,!=,<,<=,>,>= - boolean logic:
and,or,not - membership:
in - arithmetic:
+,-,*,/,//,% - string concatenation:
~ - indexing and attribute lookup:
item.nameitem.data.hostnameitems[0]item.related["rooms"]
Common control flow:
{% if condition %}...{% elif other %}...{% else %}...{% endif %}{% for item in items %}...{% endfor %}{% set value = ... %}
Common MiniJinja features available here:
- expressions and filters such as
|length,|sort,|default(...) - tests such as
is defined,is none,is string,is sequence - macros,
include,import, andextends - curated Hubuum helpers:
|csv_cell|tojsoncoalesce(...)|default_if_empty(...)|format_datetime(...)|join_nonempty(...)
Examples:
{% if item.data.owner is defined and item.data.owner %}
Owner: {{ item.data.owner }}
{% endif %}
{% if "prod" in item.data.tags %}
{{ item.name }} is production
{% endif %}
{{ item.name ~ "@" ~ item.data.hostname }}
{% if host.related.rooms|length > 0 %}
First room: {{ host.related.rooms[0].name }}
{% endif %}
{{ "alice,bob"|csv_cell }}
{{ {"host": item.name, "owner": item.data.owner}|tojson }}
{{ coalesce(item.data.primary_contact, item.data.owner, "unknown") }}
{{ item.updated_at|format_datetime("date") }}
What we do not add on top of MiniJinja:
- no custom database lookup functions from export templates
- no filesystem template loading
- no cross-collection template loading
- no relation helper functions such as
related_to(...)
Stored template composition¶
Stored export templates can compose other stored export templates in the same collection by name.
Recommended naming for reusable stored export templates:
layout.<name>macros.<name>partial.<name>export.<name>
Example layout template:
{# name: layout.html #}
<!doctype html>
<html>
<body>
{% block body %}{% endblock %}
</body>
</html>
Example macro template:
{# name: macros.txt #}
{% macro owner(item) %}{{ item.data.owner|default("unknown") }}{% endmacro %}
Example child template:
{% extends "layout.html" %}
{% import "macros.txt" as macros %}
{% block body %}
<ul>
{% for item in items %}<li>{{ item.name }} - {{ macros.owner(item) }}</li>{% endfor %}
</ul>
{% endblock %}
Composition rules:
- template names are resolved only inside the selected template's collection
- cross-collection loading is rejected
- filesystem loading is not available
- names containing
/or::are rejected by the loader
Plain text example¶
Template:
Export scope: {{ meta.scope.kind }}
Rows: {{ meta.count }}
{% for item in items %}- {{ item.name }} owned by {{ item.data.owner }}
{% endfor -%}
Rendered output:
HTML example¶
Template:
<h1>Server export</h1>
<ul>{% for item in items %}<li><strong>{{ item.name }}</strong> - {{ item.data.hostname }}</li>{% endfor %}</ul>
Rendered output:
<h1>Server export</h1>
<ul><li><strong>srv-app-01</strong> - srv-app-01.example.org</li><li><strong>srv-db-01</strong> - srv-db-01.example.org</li></ul>
Interpolated values are HTML-escaped automatically for text/html output.
CSV example¶
Template:
name,owner,hostname
{% for item in items %}{{ item.name }},{{ item.data.owner }},{{ item.data.hostname }}
{% endfor -%}
Rendered output:
Nested array example¶
Given item data like:
Template:
{% for item in items %}{{ item.name }}
{% for tag in item.data.tags %} - {{ tag }}
{% endfor %}{% endfor -%}
Rendered output:
You can also read a single array element directly:
For data keys that contain dots, spaces, or punctuation, use bracket notation:
Relation export example¶
Direct relation scopes use the normal items context. Templated related_objects exports are rooted at the requested source object: items contains that single hydrated source object, and source points to the same value.
Example executable template:
{
"collection_id": 7,
"name": "export.host-related",
"description": "Related host export",
"content_type": "text/plain",
"template": "Related objects for {{ source.name }}",
"kind": "export",
"scope_kind": "related_objects",
"class_id": 42,
"default_query": "depth__lte=2&to_classes=91&sort=path",
"default_missing_data_policy": "strict"
}
Run it with:
Template:
Related objects for {{ source.name }}
{% for item in source.reachable.rooms %}- {{ item.name }} path={{ item.path }}
{% endfor %}
For direct relation exports, class_relations items contain fields such as from_hubuum_class_id and to_hubuum_class_id, while object_relations items contain fields such as from_hubuum_object_id, to_hubuum_object_id, and class_relation_id.
Included related objects¶
objects_in_class exports can add bounded related-object arrays to each item with include.related_objects. Use this when the export is centered on one class but the template needs nearby objects, such as a host's room.
Example executable template:
{
"collection_id": 7,
"name": "export.host-room",
"description": "Host room export",
"content_type": "text/plain",
"template": "{% for item in items %}{{ item.name }}{% endfor %}",
"kind": "export",
"scope_kind": "objects_in_class",
"class_id": 42,
"default_query": "name__equals=nommo",
"include": {
"related_objects": {
"room": {
"class_id": 91,
"class_relation_id": 77,
"direction": "outgoing",
"sort": "name",
"max_depth": 1,
"limit": 1
}
}
},
"default_missing_data_policy": "strict"
}
Template:
The alias (room above) becomes item.related.room in export templates and related.room in JSON output. Included values are arrays even when limit is 1, and each related object includes its normal object fields plus path. An export can include up to 8 related-object aliases.
Use class_relation_id and direction when the relation meaning matters. Use sort (path, name, or created_at) to decide which related object appears first when the alias has a small limit. The top-level related item field is reserved for these export includes.
Missing data policy¶
Missing values are controlled by missing_data_policy on the export request.
Example template:
If primary_contact does not exist:
strict: the export task failsnull: rendersnullomit: renders an empty stringnullandomit: rendered missing lookups add a template warning that names the stored template where the missing lookup rendered
Rendered output with null:
Rendered output with omit:
Relation-aware object export_templates¶
Templated object exports can expose hydrated relation-aware objects.
Hydrated objects keep the normal object fields:
idnamedescriptioncollection_idhubuum_class_iddatacreated_atupdated_atpathpath_objects
They also add:
relatedreachablepaths
related is a map of adjacent objects grouped by relation alias. Aliases come from:
forward_template_aliasorreverse_template_aliason the class relation when set- otherwise, the adjacent class name normalized to lower snake case and pluralized predictably
Inferred aliases are normalized like this:
Room->roomsPerson->personsPolicy->policiesClass->classesAccess Policy->access_policiesPerson async->person_asyncs
Relation traversal is bidirectional in export templates, so room.related.hosts works even if the stored
relation was originally created from Host to Room.
reachable is the flattened companion to related:
related.*- direct neighbors only
- preserves the hop-by-hop graph shape
reachable.*- direct plus transitive neighbors within the remaining depth budget
- grouped by the reachable object's class alias
- deduplicated by object id
- uses the shortest visible path when the same object is reachable in multiple ways
paths.*- direct plus transitive neighbors within the remaining depth budget
- grouped by the reachable object's class alias
- preserves multiple visible routes to the same target object
- each entry exposes both
pathandpath_objects
Examples:
host.related.rooms- the rooms directly adjacent to that host
host.reachable.rooms- also the rooms directly adjacent to that host
host.reachable.persons- people reachable through one or more intermediate objects, such as
Host -> Room -> Person
When the same reachable object can be found through multiple visible paths, it appears once in the reachable alias bucket.
related_objects¶
Templated related_objects exports expose:
items- always
[source] source- the hydrated root object
The default relation depth is 2. You can override it with:
objects_in_class¶
Templated objects_in_class exports expose hydrated roots in items only when relation hydration
is enabled explicitly:
Without relation_context, items stays a plain list of objects without related.* or
reachable.*.
Host -> Room -> Person example¶
Assume:
- a
Hostobject is related to aRoom - that
Roomis related to one or morePersonobjects
For a templated related_objects export rooted at a host:
{% for host in items %}
Host: {{ host.name }}
{% for room in host.related.rooms %}
Room: {{ room.name }}
People:
{% for person in room.related.persons %}- {{ person.name }}
{% endfor %}{% endfor %}{% endfor %}
If you want the host export to flatten reachable people without manually stepping through rooms,
use reachable:
{% for host in items %}
Host: {{ host.name }}
People:
{% if host.reachable.persons is defined %}
{% for person in host.reachable.persons %}- {{ person.name }}
{% endfor %}
{% else %}
- none
{% endif %}
{% endfor %}
If alice is reachable through both room-101 and room-102, host.reachable.persons still
contains alice only once.
If you want to keep both branches, use paths:
{% for host in items %}
Host: {{ host.name }}
People by path:
{% for person in host.paths.persons %}- {{ person.name }} via {{ person.path_objects[1].name }}
{% endfor %}{% endfor %}
In that case the same alice object will appear once per visible route, so a host connected to
alice through room-101 and room-102 yields two paths.persons entries.
Rendered output:
Use related when the export should preserve the path shape and show the intermediate room. Use
reachable when the export should flatten the result to "all people this host can reach within the
configured depth".
reachable.* aliases only appear when there is at least one visible reachable object for that
class alias, so optional lookups should still be guarded in strict mode.
Using source¶
For templated related_objects exports, source is the same hydrated root object that also
appears as items[0].
Example:
Host: {{ source.name }}
People:
{% for person in source.reachable.persons %}- {{ person.name }}
{% endfor %}
Using items keeps export_templates reusable between related_objects and objects_in_class. Using
source is convenient when an export is always rooted at one object.
Rendered output:
For a class-wide host export, use objects_in_class plus relation_context.depth:
{% for host in items %}
{% for room in host.related.rooms %}
{% for person in room.related.persons %}
{{ host.name }},{{ room.name }},{{ person.name }}
{% endfor %}{% endfor %}{% endfor %}
The flatter class-wide version can use reachable instead:
{% for host in items %}
{% for person in host.reachable.persons %}
{{ host.name }},{{ person.name }}
{% endfor %}{% endfor %}
If you want a more defensive version that handles missing or empty relations cleanly:
{% for host in items %}
Host: {{ host.name }}
{% if host.related.rooms is defined and host.related.rooms %}
{% for room in host.related.rooms %}
Room: {{ room.name }}
{% if room.related.persons is defined and room.related.persons %}
People:
{% for person in room.related.persons %}- {{ person.name }}
{% endfor %}
{% else %}
People:
- none
{% endif %}
{% endfor %}
{% else %}
Room: none
{% endif %}
{% endfor %}
The same pattern also works for HTML and CSV:
<ul>{% for host in items %}<li><strong>{{ host.name }}</strong><ul>{% for room in host.related.rooms %}<li>{{ room.name }}<ul>{% for person in room.related.persons %}<li>{{ person.name }}</li>{% endfor %}</ul></li>{% endfor %}</ul></li>{% endfor %}</ul>
host,room,person
{% for host in items %}{% for room in host.related.rooms %}{% for person in room.related.persons %}{{ host.name }},{{ room.name }},{{ person.name }}
{% endfor %}{% endfor %}{% endfor %}
Limits and constraints¶
- Stored export templates support only
text/plain,text/html, andtext/csv application/jsondoes not use stored export templates; submit JSON exports withPOST /api/v1/exports- Executable export templates support every scope kind (
collections,classes,objects_in_class,class_relations,object_relations,related_objects);class_idis set only forobjects_in_classandrelated_objects, andinclude/relation_contextapply only to those scopes - Template loading for
include/import/extendsis limited to the same collection - HTML export templates are autoescaped; plain text and CSV export_templates should use
tojsonorcsv_cellwhen embedding JSON- or CSV-sensitive values - Hydrated relation export templates are limited to relation depth
<= 2 - Rendered output still respects
limits.max_output_bytes, capped byHUBUUM_EXPORT_MAX_OUTPUT_BYTES
CSV note¶
text/csv export templates are rendered as plain text.
Hubuum now provides |csv_cell for individual CSV cells. Use it instead of hand-written quoting
for fields that may contain commas, quotes, or newlines.
Example:
name,owner
{% for item in items %}{{ item.name|csv_cell }},{{ item.data.owner|default("")|csv_cell }}
{% endfor %}
Missing fields and warnings¶
Missing values are controlled by missing_data_policy:
strict- missing lookups fail the export task
null- missing lookups render as literal
null omit- missing lookups render as an empty string
Current behavior:
- rendered missing template lookups in
nullandomitmodes add a warning per stored template involved in rendering - the
warningstop-level context is still available for export warnings such as truncation - if you want to avoid failures in
strictmode, guard optional lookups explicitly with checks such as: {% if item.data.owner is defined %}{{ item.data.owner }}{% endif %}{% if host.related.rooms is defined and host.related.rooms %}...{% endif %}{% if host.reachable.persons is defined %}...{% endif %}
Typical workflow¶
- Create an executable stored template with
POST /api/v1/export-templates - Run that template with
POST /api/v1/export-templates/{template_id}/exports - Read the returned
TaskResponse, wait for completion, then fetch the rendered result fromGET /api/v1/exports/{task_id}/output
Example template run request: