Skip to content

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:

What a template receives

Templates render against a context object with these top-level keys:

  • items
  • meta
  • warnings
  • request
  • source
  • present for templated related_objects exports 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:

{
  "query": "name__contains=srv-&sort=name"
}

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.name
  • item.data.hostname
  • items[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, and extends
  • curated Hubuum helpers:
  • |csv_cell
  • |tojson
  • coalesce(...)
  • |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:

Export scope: objects_in_class
Rows: 2

- srv-app-01 owned by alice
- srv-db-01 owned by bob

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:

name,owner,hostname
srv-app-01,alice,srv-app-01.example.org
srv-db-01,bob,srv-db-01.example.org

Nested array example

Given item data like:

{
  "name": "srv-app-01",
  "data": {
    "tags": ["prod", "app"]
  }
}

Template:

{% for item in items %}{{ item.name }}
{% for tag in item.data.tags %}  - {{ tag }}
{% endfor %}{% endfor -%}

Rendered output:

srv-app-01
  - prod
  - app
srv-db-01
  - prod
  - db

You can also read a single array element directly:

{% for item in items %}{{ item.name }} primary_tag={{ item.data.tags[0] }}
{% endfor %}

For data keys that contain dots, spaces, or punctuation, use bracket notation:

{% for item in items %}{{ item.data["owner.name"] }} {{ item.data["service tier"] }}
{% endfor %}

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:

{
  "object_id": 101
}

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.

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:

{% for item in items %}{{ item.name }} is in {{ item.related.room[0].name }}
{% endfor %}

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:

{% for item in items %}{{ item.name }} owner={{ item.data.primary_contact }}
{% endfor -%}

If primary_contact does not exist:

  • strict: the export task fails
  • null: renders null
  • omit: renders an empty string
  • null and omit: rendered missing lookups add a template warning that names the stored template where the missing lookup rendered

Rendered output with null:

srv-app-01 owner=null
srv-db-01 owner=null

Rendered output with omit:

srv-app-01 owner=
srv-db-01 owner=

Relation-aware object export_templates

Templated object exports can expose hydrated relation-aware objects.

Hydrated objects keep the normal object fields:

  • id
  • name
  • description
  • collection_id
  • hubuum_class_id
  • data
  • created_at
  • updated_at
  • path
  • path_objects

They also add:

  • related
  • reachable
  • paths

related is a map of adjacent objects grouped by relation alias. Aliases come from:

  1. forward_template_alias or reverse_template_alias on the class relation when set
  2. otherwise, the adjacent class name normalized to lower snake case and pluralized predictably

Inferred aliases are normalized like this:

  • Room -> rooms
  • Person -> persons
  • Policy -> policies
  • Class -> classes
  • Access Policy -> access_policies
  • Person 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 path and path_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.

Templated related_objects exports expose:

  • items
  • always [source]
  • source
  • the hydrated root object

The default relation depth is 2. You can override it with:

"relation_context": {
  "depth": 1
}

objects_in_class

Templated objects_in_class exports expose hydrated roots in items only when relation hydration is enabled explicitly:

"relation_context": {
  "depth": 2
}

Without relation_context, items stays a plain list of objects without related.* or reachable.*.

Host -> Room -> Person example

Assume:

  • a Host object is related to a Room
  • that Room is related to one or more Person objects

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:

Host: host-01
People:
- alice
- bob

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:

Host: host-01
Room: room-101
People:
- alice
- bob

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, and text/csv
  • application/json does not use stored export templates; submit JSON exports with POST /api/v1/exports
  • Executable export templates support every scope kind (collections, classes, objects_in_class, class_relations, object_relations, related_objects); class_id is set only for objects_in_class and related_objects, and include/relation_context apply only to those scopes
  • Template loading for include/import/extends is limited to the same collection
  • HTML export templates are autoescaped; plain text and CSV export_templates should use tojson or csv_cell when 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 by HUBUUM_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 null and omit modes add a warning per stored template involved in rendering
  • the warnings top-level context is still available for export warnings such as truncation
  • if you want to avoid failures in strict mode, 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

  1. Create an executable stored template with POST /api/v1/export-templates
  2. Run that template with POST /api/v1/export-templates/{template_id}/exports
  3. Read the returned TaskResponse, wait for completion, then fetch the rendered result from GET /api/v1/exports/{task_id}/output

Example template run request:

{
  "query": "name__contains=srv-&sort=name",
  "missing_data_policy": "omit",
  "limits": {
    "max_items": 100
  }
}