template

package
v0.28.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package template turns a stored, parameterised JSON document into a validated Pulse request.

A template is valid JSON at rest with an explicit wrapper: a `target` naming the request root the rendered body decodes into, a `variables` block declaring what a caller may (or must) supply, and a `body` holding the parameterised request with its variable markers still in place. The body is deliberately NOT runnable as-is — it becomes a request only once rendered.

{
  "description": "Revenue by region",
  "target": "request",
  "variables": [
    {"name": "metric",   "type": "field",   "required": true},
    {"name": "bucket",   "type": "integer", "default": 10},
    {"name": "segments", "type": "list",    "items": "string"}
  ],
  "body": {"cohort": {"filename": "sales.pulse"}, "aggregations": []}
}

This file carries the document model only — the shapes a template file deserializes into, plus their two closed enums. Declaration validation lives in validate.go. Variable resolution, the render walk, and strict decode into the target request type arrive in later files.

Import ceiling: this package depends only on the standard library plus the pulse types and errors packages. It must never import descriptor, processing, or service — TestTemplatePackage_ImportBoundary enforces that, keeping the package dependency-light and execution-free.

Index

Constants

This section is empty.

Variables

View Source
var Unresolved any = unresolved{}

Unresolved is the value a declared variable takes when neither the caller nor the declaration supplied one.

It is a distinct sentinel rather than a nil any on purpose: a JSON null also decodes to nil, and the render walk's `$when` guards turn entirely on the resolved/unresolved distinction being exact. Test it with IsUnresolved rather than comparing against nil.

Functions

func IsUnresolved

func IsUnresolved(v any) bool

IsUnresolved reports whether v is the Unresolved sentinel. It is the only supported way to ask the question — a resolved value is never equal to Unresolved, including a resolved empty string, empty list, zero, or false.

func RenderJSON

func RenderJSON(t *Template, supplied map[string]any) (json.RawMessage, error)

RenderJSON resolves a template's variables against a caller-supplied map and walks the body, returning the rendered JSON.

It is the substitution half of rendering: the result is well-formed JSON shaped like the target request, but it has not yet been decoded into the target type. Strict decode is a separate step.

The walk applies exactly three transformations:

  1. Slot markers. `{"$var": "bucket"}` is replaced, whole, by the resolved value of `bucket` — TYPE-PRESERVING, so `{"interval": {"$var": "bucket"}}` renders to `{"interval": 10}` and never to `{"interval": "10"}`. An object is a marker only if `$var` is its single key and its value is a string; `{"$var": "x", "other": 1}` is literal data.

  2. String sugar. Inside a string VALUE, `{{name}}` interpolates the variable's natural text — strings verbatim, numbers as their exact literal, booleans as `true`/`false`, dates as their ISO string. `{{{{` is the escape for a literal `{{`. A `list` or a `period` variable has no natural text and inside a string is PULSE_TEMPLATE_VAR_TYPE — splice it with a marker instead. Object KEYS are not interpolated (declaration validation rejects `{{` in a key outright, so this is never a silent no-op).

  3. `$when` guards. `{"$when": "segments", …}` survives iff `segments` resolved; the key is stripped either way. A dropped object is REMOVED from its parent array (the slice is compacted — no null hole is left behind, which would decode to a nil operator slot) or its key is removed from its parent object. `$when` on the root body is an error.

Guards evaluate BEFORE the walk descends, so an unresolved marker inside a dropped block never raises. That ordering is the whole point of the guard: it is how an author says "this slot is optional".

Resolution is presence semantics, not truthiness. A variable resolved to "", [], 0, or false is resolved, and a block guarded on it STAYS — a legitimate zero has to be templatable.

A surviving unguarded marker or `{{token}}` naming a variable that resolved to nothing is PULSE_TEMPLATE_UNRESOLVED. A marker never silently vanishes: optionality is spelled `$when`, always.

Substituted values are spliced as data and are NOT re-walked. A caller who supplies the string "{{metric}}" gets that string verbatim in the rendered request, not a second round of interpolation.

Faults follow the family's provenance rule. A body that references a variable the template does not declare is a template AUTHOR error and is caught at declaration time by Validate, not here. A variable that is declared but resolved to nothing is PULSE_TEMPLATE_UNRESOLVED at render. Every returned error carries errors.DetailTemplate and, when the fault is variable-scoped, errors.DetailVariable, plus the body path under "path".

func Validate

func Validate(t *Template) error

Validate runs declaration validation over a template document. It checks the declaration only — it never resolves a variable, renders a body, or touches the filesystem — and returns the first fault it finds:

  • absent or unrecognised target → PULSE_TEMPLATE_TARGET_UNKNOWN
  • absent, malformed, non-object, or empty body → PULSE_TEMPLATE_INVALID
  • empty or duplicate variable name → PULSE_TEMPLATE_INVALID
  • absent or unrecognised var type → PULSE_TEMPLATE_INVALID
  • enum without values → PULSE_TEMPLATE_INVALID
  • list without items, with nested items, or with non-scalar items → PULSE_TEMPLATE_INVALID
  • a default that fails the declared type's acceptance rule → PULSE_TEMPLATE_INVALID
  • a body marker, `$when` guard, or `{{token}}` naming an undeclared variable → PULSE_TEMPLATE_INVALID
  • a malformed body marker, guard, or interpolation token → PULSE_TEMPLATE_INVALID
  • a `$when` on the root body → PULSE_TEMPLATE_INVALID

Two things are deliberately NOT faults here. Required together with a default is legal: the default resolves the variable, so the pair can never leave it unresolved. And Name is not required — a template unmarshaled directly in Go may leave it empty, because naming is the store's job (it derives the name from the file's path).

Default checking is fully semantic, not merely JSON-kind deep: an enum default is membership-checked against `values`, a date default is parsed, and a period default's ranges-XOR-table shape is enforced. It runs the identical checkValue used on caller-supplied values at render — only the provenance differs, and provenance is what selects the code. A bad default is a template AUTHOR error, so it is always PULSE_TEMPLATE_INVALID rather than a PULSE_TEMPLATE_VAR_* code, and catching it here is what makes fail-fast-at-registration real: a template with an unparseable date default must not lie in wait until someone renders it.

The body is scanned too, but only for the three pieces of template syntax it carries — slot markers, `$when` guards, and `{{token}}` interpolation. Every name they reference must be DECLARED. A body that says `{"$var": "metrc"}` when the template declares `metric` is a template author's typo, so it is PULSE_TEMPLATE_INVALID and it fails here, at registration, rather than lying in wait until someone renders. That is a different failure from a declared-but-unresolved variable, which is a render-time PULSE_TEMPLATE_UNRESOLVED and cannot be known until a caller's variable map arrives.

The scan does NOT check request semantics. Body keys, operator names, and field names stay unvalidated here — before substitution the body is not a request, and its keys are checked by the strict decode that runs after rendering.

Every returned error carries the template under errors.DetailTemplate and, when the fault is variable-scoped, the variable under errors.DetailVariable.

Types

type Rendered

type Rendered struct {
	// Target is the template's declared target — the discriminant that
	// says which typed pointer below is populated.
	Target Target

	// JSON is the rendered body exactly as RenderJSON produced it,
	// before strict decode. It is always populated.
	JSON json.RawMessage

	// Request is populated iff Target is TargetRequest.
	Request *types.Request

	// Composed is populated iff Target is TargetComposed.
	Composed *types.ComposedRequest

	// Chain is populated iff Target is TargetChain.
	Chain *types.ChainRequest

	// Facet is populated iff Target is TargetFacet.
	Facet *types.FacetRequest

	// Sample is populated iff Target is TargetSample.
	Sample *types.SampleRequest
}

Rendered is the result of a complete render: the substituted JSON plus the typed request it decoded into.

Exactly one of the five typed pointers is non-nil, and which one is decided solely by Target. There are no generics and no per-target wrapper types — the caller reads the pointer its target names and hands it straight to the matching facade method (Process, Compose, ProcessChain, FacetSchema, SampleWithRequest). A caller that does not know the target ahead of time can switch on Target, or take the interface value from Typed.

JSON is retained alongside the typed value because the two answer different questions. The typed request is what gets executed; the raw JSON is what a caller shows a human, diffs against an expectation, or stores as the record of what a render produced. Re-marshaling the typed value would not reproduce it — every request struct is dense with omitempty, so a slot that rendered to an explicit zero would silently vanish from a round trip.

func Render

func Render(t *Template, supplied map[string]any) (*Rendered, error)

Render turns a template plus a caller-supplied variable map into a typed, executable request. It is the package's terminal operation and the only one most callers need.

It is RenderJSON followed by a strict decode into the type Target names; variables are resolved exactly once, by RenderJSON. Every fault the render walk can raise (PULSE_TEMPLATE_INVALID, PULSE_TEMPLATE_TARGET_UNKNOWN, PULSE_TEMPLATE_VAR_*, PULSE_TEMPLATE_UNRESOLVED) surfaces from here unchanged — Render adds exactly one new failure mode, PULSE_TEMPLATE_RENDER_INVALID, for substituted JSON that does not fit the target request type.

The decode is STRICT: json.Decoder with DisallowUnknownFields. That is deliberately harsher than the rest of Pulse, which tolerates unknown fields — that tolerance is exactly how the examples/ `_meta` sidecar block survives at execution. Here a typo in a stored template must fail loudly at render rather than silently drop a request slot, so an unrecognised key is a hard error and the message names it.

Render never opens a cohort file, and the package it lives in cannot: nothing on the render path imports a filesystem API. Field existence, operator/field type compatibility, and streamability stay Predict's job, unchanged. A template that renders is well-formed against the request SHAPE; whether it is executable against a particular cohort is a separate question with a separate answer.

func (*Rendered) Typed

func (r *Rendered) Typed() any

Typed returns the one populated request pointer as an interface value, selected by Target. It is the target-agnostic accessor — useful to a caller that dispatches on the concrete type rather than on Target.

Nil-safe, and returns nil for a zero Rendered, so a nil result always means "nothing was decoded" rather than "the wrong arm was read".

type Resolution

type Resolution struct {
	// contains filtered or unexported fields
}

Resolution is a template's declared variables resolved against a caller-supplied map: every declared name mapped either to its concrete JSON value or to Unresolved. It is the sole input to the render walk's substitution and `$when` guard evaluation.

Resolved values are decoded with json.Number in place of float64, so an integer supplied by the caller reaches the rendered request as the exact literal it was written as — a u64 field value never round-trips through float64 and never loses its low bits.

A Resolution is read-only once returned; every accessor is nil-safe.

func Resolve

func Resolve(t *Template, supplied map[string]any) (*Resolution, error)

Resolve checks a caller-supplied variable map against a template's declarations and returns the resolved set. It performs no rendering and touches no filesystem.

Resolution order, per declared variable: caller-supplied value, then the declaration's `default`, then Unresolved. A supplied nil (or a supplied JSON null) means "supplied nothing" and falls through to the default, mirroring the declaration side where an explicit null `default` means "no default". A supplied "", [], 0, or false is a real value and resolves — the render walk's `$when` guards are presence semantics, not truthiness, so a legitimate zero stays templatable.

Faults, in the order they are detected:

  • a declaration fault of any kind → whatever Validate reports
  • a supplied name the template does not declare → PULSE_TEMPLATE_VAR_UNKNOWN
  • a supplied value whose JSON kind or value contradicts its declared type → PULSE_TEMPLATE_VAR_TYPE
  • a supplied string outside an enum's declared `values` → PULSE_TEMPLATE_VAR_ENUM
  • a `required` variable resolving to nothing → PULSE_TEMPLATE_VAR_MISSING

The error code is chosen by the value's PROVENANCE, not by when the fault is found. A bad caller-supplied value is a caller error and gets the PULSE_TEMPLATE_VAR_* code. A bad declared `default` is a template AUTHOR error and gets PULSE_TEMPLATE_INVALID wherever it surfaces — which is why Validate runs the identical value check over defaults at declaration time, so a template carrying an unparseable date default fails when it is registered rather than lying in wait until someone renders it.

Every returned error carries the template under errors.DetailTemplate and, when the fault is variable-scoped, the variable under errors.DetailVariable.

func (*Resolution) All

func (r *Resolution) All() map[string]any

All returns the full resolved set as a map, with Unresolved standing in for every variable that did not resolve. The returned map is a copy; mutating it does not affect the Resolution.

func (*Resolution) Declared

func (r *Resolution) Declared(name string) bool

Declared reports whether name is one of the template's declared variables, regardless of whether it resolved.

func (*Resolution) Get

func (r *Resolution) Get(name string) (any, bool)

Get returns the resolved value for name and whether it resolved. An undeclared name and an unresolved variable both report false — the render walk treats them identically, since neither can be substituted. Use Declared to tell them apart.

func (*Resolution) IsResolved

func (r *Resolution) IsResolved(name string) bool

IsResolved reports whether name resolved to a concrete value. A value of "", [], 0, or false is resolved — resolution is presence semantics, not truthiness.

func (*Resolution) Len

func (r *Resolution) Len() int

Len reports how many variables the resolution covers, resolved and unresolved alike.

func (*Resolution) Names

func (r *Resolution) Names() []string

Names returns the declared variable names in author order. The returned slice is a copy.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is a name→template lookup built from an ordered list of directory roots. It is what makes a template a file rather than a Go literal.

Discovery

Each configured root is walked with filepath.WalkDir and every *.json file under it becomes a template. Directories and non-.json files are skipped silently. A configured root that does not exist is skipped too — a layered setup routinely names an optional override directory that is simply absent — but a configured path that exists and is NOT a directory is an error, because that is a misconfiguration rather than an absence.

Naming

A template's name is its path relative to its OWN root, minus the .json extension, forward-slash separated regardless of host. A file at templates/finance/revenue.json under the root templates/ is named finance/revenue. Subdirectories therefore namespace for free, and the root's own location never leaks into the name — which is what lets the same relative layout be served from two different roots.

The derived name is authoritative and is stamped onto Template.Name. A document may repeat its own name, but a document claiming a name its path does not give it is a PULSE_TEMPLATE_INVALID: silently renaming an author's template is exactly the class of surprise this surface exists to remove.

Within a single root, two files can never derive the same name — the mapping is invertible, since appending ".json" to a name recovers the relative path exactly. Collisions are therefore always cross-root.

Precedence

Roots are an ordered precedence list and the FIRST root wins. A duplicate name across roots is not an error: the earlier root's entry answers lookups and the later ones are recorded as shadowed, surfacing in Summary.Shadows so a layered setup is visible rather than mysterious.

Precedence is resolved at LOOKUP time, not baked in at construction. That is deliberate: hot reload adds files to the index after the fact, and a file dropped into a lower-precedence root must never be able to displace — or break — a name that already worked.

Validation

Every discovered file is read, parsed, and declaration-validated during construction, so a malformed template fails at startup with the offending path named rather than lying in wait until someone renders it. Parse alone cannot name the file (a document whose bytes do not parse has no knowable name), so the store re-raises every parse fault with the path and the derived name attached, preserving the original code.

Hot reload

The index is a cached snapshot, not a boot-time freeze. A lookup whose snapshot has aged past templateRescanInterval re-walks the configured roots first, so a template file dropped into a scanned directory becomes renderable without restarting the process — and a deleted one stops resolving. Precedence is re-resolved from the fresh index, so a file appearing in a higher-precedence root starts shadowing a lower one and removing it hands the name back.

A rescan is a directory walk plus one os.Stat per candidate file. A file whose size AND modification time both match the parsed copy already held is reused as-is, so a steady-state rescan over a directory of unchanged templates costs syscalls and no JSON parsing at all.

Reload forces the walk immediately, ignoring the interval, for callers who need determinism rather than eventual visibility.

Degradation

Construction and rescan judge a broken document differently, on purpose.

At CONSTRUCTION a malformed file is a deploy error and fails outright: the operator is standing there, and starting a process over a directory the author has not finished is worse than not starting.

After construction a malformed file is almost always a transient edit — a half-written save, a typo mid-keystroke — and the degradation is strictly PER FILE:

  • A template that parsed before and no longer does keeps serving its LAST-GOOD parse. A running system keeps running.
  • A file that has never parsed has no last-good, so its name resolves to PULSE_TEMPLATE_INVALID naming the path.
  • Every other template in the store is untouched, including siblings in the same directory.
  • Reload does NOT report a per-file document fault. Returning one would mask an otherwise healthy catalog behind one operator's keystroke. Brokenness is observable through List instead: the affected entry reports Summary.Broken with the fault in Summary.Error.
  • Repairing the file clears the broken state on the next rescan.

Root-level faults stay whole-walk errors and Reload still reports them: a configured root that exists but is not a directory is a misconfiguration, not a transient edit, and there is no per-file scope to degrade to.

Concurrency

Every exported method is safe for concurrent use, and the zero-value nil *Store is usable: it lists nothing and reports every lookup as PULSE_TEMPLATE_NOT_FOUND. That keeps "no template directories configured" an ordinary answer rather than a nil check at every call site.

func NewStore

func NewStore(dirs []string) (*Store, error)

NewStore walks dirs in order and returns a store over every *.json template found beneath them. Roots earlier in the slice take precedence over later ones.

Empty and blank root entries are skipped, as are roots that do not exist. A root that exists but is not a directory, an unreadable file, and any document that fails declaration validation are all errors — construction is the fail-fast point, so nothing malformed survives into the index.

An empty dirs slice yields a usable empty store rather than an error: "no template directories configured" is a normal deployment, not a fault.

func (*Store) Dirs

func (s *Store) Dirs() []string

Dirs returns the configured roots in precedence order, as a copy. It answers "where is this store looking?" for diagnostics and for the rescan that layers on top of it.

func (*Store) Get

func (s *Store) Get(name string) (*Template, error)

Get returns the template registered under name. Lookup is exact and case-sensitive, and the name is the derived one — "finance/revenue", not "finance/revenue.json" and not the absolute path.

When two roots carry the same name the earlier root's template is returned; the shadowed ones are reachable through List. An unregistered name — including every name on a nil store — is PULSE_TEMPLATE_NOT_FOUND carrying the requested name under errors.DetailTemplate.

A lookup is also the store's clock: when the cached snapshot has aged past templateRescanInterval the configured roots are re-walked first, so a template added to a directory after startup resolves here without a restart. Reload forces that walk immediately.

A name whose file has broken since it was last parsed still resolves — to the last-good parse. A running system keeps running through a half-written save, and the broken state is reported by List rather than by failing this call. Only a name with NO last-good parse — a file that was already malformed the first time the store saw it — surfaces the fault here, as PULSE_TEMPLATE_INVALID naming the path.

The returned template is the store's own copy and must be treated as read-only; rendering never mutates it.

func (*Store) List

func (s *Store) List() []Summary

List returns one summary per registered name, sorted by name, so the order is deterministic across runs and platforms.

A shadowed entry gets no summary of its own — it is not renderable, and a listing whose entries cannot all be fetched would be a trap. It surfaces instead on the winner's Summary.Shadows, which names the source paths the winning entry takes precedence over. That is what makes "my override is not taking effect" answerable from the listing alone.

Like Get, a listing rescans first when the cached snapshot has aged past templateRescanInterval, so a listing reflects the directories rather than the moment the process started.

The listing is also where brokenness is visible. A file that has stopped parsing since it was loaded keeps answering Get with its last-good copy, so nothing else in the store would reveal it; its summary carries Broken=true and the fault text in Error, which is what lets an operator find the bad file without rendering all fifty templates one at a time. A file that has NEVER parsed is listed the same way but with no declaration to project — no Target, no Variables — so it is visibly not fetchable.

Returns nil for a nil store.

func (*Store) Reload

func (s *Store) Reload() error

Reload re-walks every configured root immediately and swaps in the result, ignoring the rescan interval. It is the determinism escape hatch: lookups pick new and changed files up on their own within templateRescanInterval, and Reload is for the caller who needs the answer now rather than shortly.

It is also what makes hot reload testable without sleeping. A test that waited out a one-second interval would be slow in the good case and flaky in the bad one; a test that calls Reload asserts the same behaviour deterministically.

It deliberately does NOT report a per-file document fault. A template that stopped parsing is one file's problem: the store keeps serving that name's last-good parse, every other template is untouched, and an error here would tell a caller its whole catalog failed when 49 of 50 templates are fine. Brokenness surfaces through List instead — Summary.Broken with the fault in Summary.Error — and through Get for a name that has no last-good parse to serve. The same applies to an unreadable file: it is one file, and it degrades like one.

What it does return is a whole-walk fault: a configured root that exists but is not a directory, or a directory that cannot be walked. Those are misconfigurations rather than transient edits, and there is no per-file scope to degrade to. A failed walk leaves the previous index entirely in place.

A nil store has nothing to walk and returns nil, so "no template directories configured" needs no nil check at the call site.

type Summary

type Summary struct {
	// Name is the template's lookup identifier.
	Name string `json:"name"`

	// Description mirrors Template.Description.
	Description string `json:"description,omitempty"`

	// Target mirrors Template.Target.
	Target Target `json:"target"`

	// Variables lists the declared variable names in author order. The
	// full declarations are reachable by fetching the template itself.
	Variables []string `json:"variables,omitempty"`

	// Path is the source file the winning entry was loaded from. Empty
	// for a template registered programmatically.
	Path string `json:"path,omitempty"`

	// Shadows lists the source paths of same-named templates this entry
	// takes precedence over. Template directories are an ordered
	// precedence list and the first directory wins; the losing entries
	// are recorded here rather than discarded, so a layered setup is
	// visible instead of mysterious. Empty when nothing is shadowed.
	Shadows []string `json:"shadows,omitempty"`

	// Broken reports that the source file at Path failed to parse or
	// validate on the most recent scan.
	//
	// It is the only place a post-startup breakage is visible. A template
	// that parsed once and has since been broken keeps ANSWERING with its
	// last-good parse — a half-written editor save must not take a running
	// system down — so nothing about fetching it would reveal the fault.
	// This flag is what lets an operator find the bad file without
	// rendering every template in the catalog one at a time.
	//
	// A Broken entry whose Target is empty never parsed at all: the file
	// was already malformed the first time the store saw it, so there is
	// no last-good copy and no declaration to project. That entry is
	// listed to be SEEN, not fetched — asking for it by name returns
	// PULSE_TEMPLATE_INVALID.
	Broken bool `json:"broken,omitempty"`

	// Error is the parse or validation fault behind Broken, already
	// naming the source file. Empty when Broken is false.
	Error string `json:"error,omitempty"`
}

Summary is the lightweight projection of a template used for listing — everything a caller needs to choose a template and build a form for it, without the body. It is what ListTemplates reports.

type Target

type Target string

Target names the public request root a rendered template decodes into. It is a closed enum, spelled lowercase on the wire; the value selects the strict-decode type at render, so it can never be inferred from the body shape. An absent or unrecognised target is PULSE_TEMPLATE_TARGET_UNKNOWN.

const (
	// TargetRequest renders into types.Request — the single-cohort
	// process/predict envelope and the 95% case.
	TargetRequest Target = "request"

	// TargetComposed renders into types.ComposedRequest — a multi-slot
	// Compose batch with optional Compose-host overlays.
	TargetComposed Target = "composed"

	// TargetChain renders into types.ChainRequest — a source-rooted
	// linear ProcessChain.
	TargetChain Target = "chain"

	// TargetFacet renders into types.FacetRequest — the facet endpoints.
	TargetFacet Target = "facet"

	// TargetSample renders into types.SampleRequest — record sampling.
	TargetSample Target = "sample"
)

func AllTargets

func AllTargets() []Target

AllTargets returns every valid Target in declaration order. The returned slice is a copy — callers may sort or mutate it freely.

func (Target) String

func (t Target) String() string

String returns the on-the-wire spelling of the target.

func (Target) Valid

func (t Target) Valid() bool

Valid reports whether t is one of the five recognised targets. The empty string is not valid: a template must state its target explicitly.

type Template

type Template struct {
	// Name is the template's lookup identifier. It is derived by the
	// store from the file's path relative to its directory root (minus
	// the .json extension, forward-slash separated); a document may also
	// carry its own name, and a Template built directly in Go may leave
	// it empty. Declaration validation does not require it — naming is
	// the store's job.
	Name string `json:"name,omitempty"`

	// Description is optional human-facing prose describing what the
	// template produces.
	Description string `json:"description,omitempty"`

	// Target names the request root the rendered body decodes into.
	// Required.
	Target Target `json:"target"`

	// Variables declares every parameter the template accepts, in
	// author order. A template with no variables is legal — it renders
	// to a constant request.
	Variables []*Variable `json:"variables,omitempty"`

	// Body is the parameterised request, held as raw JSON with its
	// variable markers intact. Required, and must be a non-empty JSON
	// object. It is not a valid request until rendered.
	Body json.RawMessage `json:"body"`
}

Template is the parsed template document.

func Parse

func Parse(data []byte) (*Template, error)

Parse decodes a template document from its JSON bytes and runs declaration validation on the result. It is the single entry point the store uses for every discovered file, so a malformed document is rejected at load rather than at first render.

Malformed JSON returns PULSE_TEMPLATE_INVALID wrapping the decoder fault. That error carries no template detail — the bytes did not parse, so the document's own name is unknowable; the caller (which knows the file path) is the one positioned to name it. Every error raised after the decode succeeds carries errors.DetailTemplate.

The document wrapper is decoded strictly: an unknown key on the wrapper, or on a variable declaration, is PULSE_TEMPLATE_INVALID. A typo'd "varaibles" would otherwise parse cleanly into a template with zero variables and fail much later — or worse, render a request with every marker unresolved — which is exactly the silent-failure class this whole surface exists to eliminate.

The strictness stops at `body`. The body stays raw JSON here and is strict-decoded against its target request type after rendering, since before substitution it is not a request and its markers are not request fields.

func (*Template) Summarize

func (t *Template) Summarize(path string, shadows []string) Summary

Summarize projects the template into a Summary, attaching the source path it was loaded from and the source paths of any same-named entries it shadows. Both may be empty. Nil-safe; returns the zero Summary for a nil template.

func (*Template) Variable

func (t *Template) Variable(name string) (*Variable, bool)

Variable returns the declaration for name and whether it exists. Lookup is exact and case-sensitive. Nil-safe.

func (*Template) VariableNames

func (t *Template) VariableNames() []string

VariableNames returns the declared variable names in author order. Nil-safe; returns nil for a template with no declarations.

type VarType

type VarType string

VarType is the declared type of a template variable. It is a closed enum: an unrecognised type is a declaration error, never a pass-through.

The scalar members (string, number, integer, boolean, field, date) are the only types permitted as a list's element type — see IsScalar.

const (
	// VarString accepts any JSON string.
	VarString VarType = "string"

	// VarNumber accepts any JSON number, integral or fractional.
	VarNumber VarType = "number"

	// VarInteger accepts a JSON number with no fractional part; 1 and 1.0
	// qualify, 1.5 does not.
	VarInteger VarType = "integer"

	// VarBoolean accepts a JSON bool.
	VarBoolean VarType = "boolean"

	// VarField accepts a JSON string naming a cohort field. It is
	// shape-identical to VarString and exists as a distinct type so that
	// cohort-bound field-name constraining can be layered on later
	// without a wire change.
	VarField VarType = "field"

	// VarEnum accepts a JSON string that is a member of the declaration's
	// Values set. A VarEnum declaration without Values is invalid.
	VarEnum VarType = "enum"

	// VarList accepts a JSON array whose every element satisfies the
	// declaration's Items type. A VarList declaration without Items, or
	// with a non-scalar Items, is invalid — lists do not nest.
	VarList VarType = "list"

	// VarDate accepts a JSON string parsing as an ISO date.
	VarDate VarType = "date"

	// VarPeriod accepts a JSON object carrying exactly one of `ranges` or
	// `table`, mirroring the GROUP_DATE_RANGES / FILTER_DATE_RANGES
	// labeled-date-range Params shape.
	VarPeriod VarType = "period"
)

func AllVarTypes

func AllVarTypes() []VarType

AllVarTypes returns every valid VarType in declaration order. The returned slice is a copy.

func (VarType) IsScalar

func (v VarType) IsScalar() bool

IsScalar reports whether v denotes a single JSON scalar and is therefore legal as a list's element type.

The three non-scalars are excluded for distinct reasons: list would nest (and a nested list has no element-type slot to declare); period is a JSON object, not a scalar; enum is scalar on the wire but draws its membership set from the variable-level Values slot, which a list declaration cannot express per-element unambiguously.

func (VarType) String

func (v VarType) String() string

String returns the on-the-wire spelling of the variable type.

func (VarType) Valid

func (v VarType) Valid() bool

Valid reports whether v is one of the nine recognised variable types. The empty string is not valid: every variable must state its type.

type Variable

type Variable struct {
	// Name is the variable's identifier — the key a caller supplies it
	// under and the token the body's markers reference. Required,
	// non-empty, unique within a template.
	Name string `json:"name"`

	// Type is the declared type. Required; must be one of AllVarTypes.
	Type VarType `json:"type"`

	// Description is optional human-facing prose. It exists so a caller
	// can build a form (or a prompt) from the declaration alone.
	Description string `json:"description,omitempty"`

	// Required marks a variable that must resolve to a value at render.
	// Required together with a Default is legal, and the pair is not a
	// contradiction: the default resolves the variable, so a required
	// variable carrying a default can never go missing.
	Required bool `json:"required,omitempty"`

	// Default is the value used when the caller supplies none, held as
	// raw JSON so integer fidelity survives to the render walk. An
	// explicit JSON null means "no default", identical to omitting it.
	Default json.RawMessage `json:"default,omitempty"`

	// Values is the permitted member set for a VarEnum variable.
	// Membership is exact and case-sensitive. Required for VarEnum,
	// meaningless for every other type.
	Values []string `json:"values,omitempty"`

	// Items is the element type for a VarList variable. Required for
	// VarList, meaningless for every other type, and must name a scalar
	// type — lists do not nest.
	Items VarType `json:"items,omitempty"`
}

Variable is one declared template parameter. The declaration is the contract a caller's supplied variable map is checked against: it fixes the accepted JSON type, whether a value is mandatory, and what stands in when the caller supplies nothing.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL