BBahmni 한국어 매뉴얼검색
한국어 번역 완료
한국어English

Bahmni Template Service Configuration Guide

Template Authoring Guide

This guide covers everything an implementer needs to create, configure, and customise clinical document templates for the Bahmni Template Service.

1. Overview

Each document is a template folder containing up to four files:

FileRequiredPurpose
template.htmlYesNunjucks HTML template — what gets rendered and printed
data-config.jsonRecommendedDeclares which OpenMRS APIs to fetch (sources only)
compute.jsOptionalExtracts and transforms data from resolved sources; fields returned here become {{ computed.* }} in the template
styles.cssOptionalExtra CSS that is inlined into the rendered HTML

Pattern:

  • data-config.json declares what to fetch (FHIR / REST endpoints).
  • compute.js decides what to do with it — extract fields, apply logic, validate inputs.
  • template.html only renders — no data logic.

The service renders a template in this order:

Request (templateId + context + data)
  → fetch data-config.json sources  (FHIR / REST calls in parallel)
  → run compute.js  (receives resolved sources + request data, returns fields)
  → render template.html  (Nunjucks — uses {{ computed.* }}, {{ data.* }})
  → inline styles.css  (if present)
  → return HTML

Template file edits are picked up immediately on the next request — no service restart needed.

2. Folder structure

print-templates/
├── templates.json              ← central registry of all templates
├── _i18n/
│   ├── en.json                 ← English translations
│   └── fr.json                 ← French translations (add more as needed)
├── _base/
│   ├── portrait.html           ← base layout for portrait pages
│   └── landscape.html          ← base layout for landscape pages
├── registration-card/
│   ├── template.html
│   ├── data-config.json
│   └── compute.js
└── prescriptions/
    ├── template.html
    ├── data-config.json
    ├── compute.js
    └── styles.css

3. templates.json — template registry

Location: print-templates/templates.json

{
  "templates": [
    {
      "id": "REG_CARD_V1",
      "name": "Registration Card",
      "folder": "registration-card"
    },
    {
      "id": "PRESCRIPTION_V1",
      "name": "Prescription",
      "folder": "prescriptions"
    }
  ]
}
FieldRequiredDescription
idYesUnique identifier — used in the render API call (templateId)
nameYesHuman-readable name (returned by the list API)
folderYesSubfolder name under print-templates/

4. data-config.json — declaring data sources

Location: print-templates/<folder>/data-config.json

data-config.json contains only a sources block. It declares which OpenMRS endpoints to call. The raw responses are passed to compute.js via resolved.

{
  "sources": {
    "<sourceName>": {
      "api": "fhir" | "rest",
      "resource": "<path with {{contextVar}} placeholders>",
      "params": { "<key>": "<value>" | ["<value1>", "<value2>"] }
    }
  }
}

A param value can be a plain string (single value) or an array of strings (multiple values). Arrays append the same key multiple times &mdash; the standard FHIR multi-value pattern used by _include, _revinclude, _elements, and similar repeating params:

"_include": "MedicationRequest:encounter"
"_include": ["MedicationRequest:encounter", "MedicationRequest:medication"]

{{contextVar}} placeholders are substituted from the render request context (e.g. {{patientUuid}}). A missing variable causes a 400 error.

URL construction:

apiHow the URL is built
fhir<OPENMRS_URL>/openmrs/ws/fhir2/R4/<resource>?<params> &mdash; resource is just the FHIR resource name, e.g. Patient
rest<OPENMRS_URL><resource>?<params> &mdash; resource must be the full path from the domain root, e.g. /openmrs/ws/rest/v1/patientprofile/{{patientUuid}}

Example:

{
  "sources": {
    "patient": {
      "api": "fhir",
      "resource": "Patient",
      "params": { "_id": "{{patientUuid}}" }
    },
    "relatives": {
      "api": "fhir",
      "resource": "RelatedPerson",
      "params": { "patient": "{{patientUuid}}" }
    },
    "patientProfile": {
      "api": "rest",
      "resource": "/openmrs/ws/rest/v1/patientprofile/{{patientUuid}}",
      "params": { "v": "full" }
    },
    "medicationRequests": {
      "api": "fhir",
      "resource": "MedicationRequest",
      "params": {
        "patient": "{{patientUuid}}",
        "_include": ["MedicationRequest:encounter", "MedicationRequest:medication"],
        "_sort": "-_lastUpdated",
        "_count": "100"
      }
    }
  }
}

Error behaviour:

HTTP statusBehaviour
200Returns response data
400Returns empty Bundle { resourceType: 'Bundle', entry: [] } &mdash; does not throw
401Throws &mdash; service returns a 401 session-expired response
404Throws &mdash; service returns a 404 not-found response
Network timeoutThrows &mdash; service returns a 502 response

5. compute.js &mdash; data extraction and transformation

Location: print-templates/<folder>/compute.js

compute.js receives the raw API responses via resolved and any caller-supplied data via data. It returns a plain object whose keys are available in the template as {{ computed.<key> }}.

Contract

module.exports = {
  compute: async function ({ context, resolved, data, ValidationError, fhirPath, translate, locale }) {
    // validate required context
    if (!context?.patientUuid) throw new ValidationError('patientUuid is required');

    const patient = resolved?.patient?.entry?.[0]?.resource;

    // fhirPath — cleaner alternative to deep optional-chaining
    const patientName = fhirPath(patient, 'Patient.name.first().text') ?? '';
    const phone       = fhirPath(patient, "Patient.telecom.where(system='phone').value") ?? '';

    // use caller-supplied data if present
    const printedBy = data?.printedBy ?? '';

    // translate a key using the request locale
    const genderLabel = translate(patient?.gender ?? '');

    return { patientName, phone, printedBy, genderLabel };
  },
};

Parameters

ParameterTypeDescription
contextobjectUUIDs from the render request (patientUuid, visitUuid, etc.)
resolvedobjectRaw API responses keyed by source name from data-config.json
dataobjectFree-form JSON object passed in the render request data field
ValidationErrorclassThrow this to return a 400 error to the caller
fhirPathfunction(resource, expression)FHIRPath evaluator &mdash; returns a single value, an array, or null
translatefunction(key, overrideLocale?)Returns the translated string for key using the request locale, falling back to English then the raw key
localestringThe BCP 47 locale from the render request (e.g. "en", "fr")

fhirPath is the same evaluator as the \\| fhirpathEvaluate Nunjucks filter. Use it in compute.js when you want to query a FHIR resource with a full FHIRPath expression rather than manual optional-chaining.

translate mirrors the | t Nunjucks filter. Use it when the compute script needs to choose which translation key to look up based on data &mdash; logic that can't be expressed in the template.

// produce a translated label based on a FHIR code
const gender = resolved?.patient?.entry?.[0]?.resource?.gender; // 'male' | 'female'
return {
  genderLabel: translate(gender),            // → 'Homme' when locale is 'fr'
  genderLabelEn: translate(gender, 'en'),    // → 'Male' always
};
  • FHIR sources return Bundles &mdash; resources are inside entry[].resource.
  • REST sources return the response body directly.
  • resolved is undefined when there is no data-config.json.
  • data is undefined when not supplied in the request.
  • Always use optional chaining (?.) &mdash; bundles can be empty.
  • Never make raw HTTP calls or hardcode credentials. All fetching belongs in data-config.json.

context &mdash; available keys

KeyWhen present
patientUuidAlways
visitUuidPrint triggered from visit context
encounterUuidPrint triggered from encounter context

Return value

Must be a plain object. Returning null, undefined, or an array skips the result silently.

6. template.html &mdash; rendering

Location: print-templates/<folder>/template.html

Templates use Nunjucks &mdash; JavaScript's equivalent of Jinja2.

Template variables

VariableDescription
{{ computed.<key> }}Fields returned from compute.js
{{ data.<key> }}Free-form data passed in the render request data field
{{ <sourceName> }}Raw API response object from data-config.json sources &mdash; available directly, no compute.js needed
{{ locale }}BCP 47 locale tag, e.g. "en", "fr"
{{ now }}Date object at render time

Using source objects directly in templates

Every source declared in data-config.json is available as a top-level variable in the template under its source name. You can navigate the raw response directly without going through compute.js.

Given this data-config.json:

{
  "sources": {
    "patient": { "api": "fhir", "resource": "Patient", "params": { "_id": "{{patientUuid}}" } }
  }
}

The full FHIR Bundle is available as {{ patient }} in the template:

{# access nested fields directly #}
<p>{{ patient.entry[0].resource.name[0].text }}</p>
<p>{{ patient.entry[0].resource.birthDate }}</p>
<p>{{ patient.entry[0].resource.gender }}</p>

{# loop over bundle entries #}
{% for entry in patient.entry %}
  <p>{{ entry.resource.name[0].text }}</p>
{% endfor %}

{# use fhirpathEvaluate for complex expressions #}
<p>{{ patient | fhirpathEvaluate("Bundle.entry.first().resource.name.first().text") }}</p>

REST sources work the same way &mdash; the response body is available directly:

"patientProfile": {
  "api": "rest",
  "resource": "/openmrs/ws/rest/v1/patientprofile/{{patientUuid}}",
  "params": { "v": "full" }
}
<p>{{ patientProfile.patient.auditInfo.dateCreated | dateFormat }}</p>

When to use direct source access vs compute.js:

ApproachUse when
Direct {{ sourceName.* }}Simple field display, single template, no transformation needed
compute.jsLogic, grouping, conditionals, cross-source joins, or reuse across templates

Extending base layouts

{% extends "_base/landscape.html" %}

{% block title %}{{ 'REGISTRATION_CARD' | t }}{% endblock %}

{% block styles %}
  /* your custom CSS here */
{% endblock %}

{% block content %}
  <!-- your content here -->
{% endblock %}

Common Nunjucks patterns

Display with fallback:

{{ computed.patientName | default('Unknown') }}

Conditional:

{% if computed.photoUrl %}
  <img src="{{ computed.photoUrl }}" style="width: 20mm; height: 20mm;" />
{% endif %}

{% if data.showWatermark %}
  <div class="watermark">DRAFT</div>
{% endif %}

Loop over a list:

{% for drug in computed.medications %}
  <tr>
    <td>{{ drug.drugName }}</td>
    <td>{{ drug.dose | default('—') }}</td>
  </tr>
{% else %}
  <tr><td colspan="2">No medications.</td></tr>
{% endfor %}

Loop with index:

{% for item in computed.items %}
  <p>{{ loop.index }}. {{ item.name }}</p>
{% endfor %}

7. Nunjucks filter reference

Custom filters registered by the service. All available in every template.

| t &mdash; translate

Looks up a key in _i18n/<locale>.json. Falls back to English, then to the raw key.

{{ 'PATIENT_NAME' | t }}
{{ 'WEIGHT' | t('fr') }}               {# force French regardless of request locale #}
{{ 'NAME' | t }} / {{ 'NAME' | t('en') }}  {# bilingual label #}

| barcode(type, height) &mdash; traditional barcode

Generates a barcode as a base64-encoded PNG <img> tag. Output is HTML-safe.

type is any valid bwip-js bcid. Common types:

TypeDescription
code128Most common &mdash; alphanumeric, compact
code39Older standard &mdash; uppercase letters and digits only
pdf4172D, higher data density
datamatrix2D square matrix
{{ computed.patientId | barcode('code128', 40) }}

Falls back to <span class="barcode-fallback">value</span> if generation fails.

| qrcode(size) &mdash; QR code

Generates a QR code as an inline SVG. Output is HTML-safe. size is width/height in pixels (default: 120).

{{ computed.patientId | qrcode(80) }}

Falls back to <span class="qrcode-fallback">value</span> if generation fails.

Note: Put the QR code in the same <td> as the patient ID text &mdash; not a separate extra column.

<td>
  {{ computed.patientId | default('') }}<br>
  {{ computed.patientId | qrcode(80) }}
</td>

| dateFormat &mdash; format a date

Formats an ISO 8601 date string using the request locale.

{{ computed.registrationDate | dateFormat }}    {# → "05 May 2026" #}

| age &mdash; compute age from birthdate

Returns a human-readable age string. Prefer computing age in compute.js and returning it directly &mdash; use this filter only when you have a raw birthdate available in the template.

{{ computed.birthDate | age }}    {# → "33 years" / "4 months" / "14 days" #}

| round(decimals) &mdash; round a number

{{ computed.bmi | round(1) }}    {# → "24.3" #}

| fhirpathEvaluate(expression) &mdash; inline FHIRPath

Evaluates a FHIRPath expression on an object in the template. Useful for per-row extraction inside {% for %} loops over raw FHIR resources.

{% for entry in patient.entry %}
  <td>{{ entry.resource | fhirpathEvaluate("Patient.name.first().text") }}</td>
{% endfor %}

Nunjucks built-in filters

FilterExampleOutput
\\| upper{{ computed.gender \\| upper }}MALE
\\| lower{{ 'HELLO' \\| lower }}hello
\\| capitalize{{ computed.gender \\| capitalize }}Male
\\| default(val){{ computed.phone \\| default('N/A') }}N/A if empty
\\| first{{ computed.items \\| first }}First element
\\| last{{ computed.items \\| last }}Last element
\\| length{{ computed.items \\| length }}Count
\\| join(', '){{ computed.tags \\| join(', ') }}"a, b, c"
\\| truncate(n){{ computed.notes \\| truncate(80) }}First 80 chars + &hellip;

Full list: Nunjucks built-in filters

8. i18n &mdash; translations

Location: print-templates/_i18n/<locale>.json

{
  "PATIENT_NAME": "Patient Name",
  "AGE":          "Age",
  "GENDER":       "Gender",
  "REGISTRATION": "Registration No.",
  "DATED":        "Date"
}

Add a new locale by creating _i18n/<locale-code>.json. Both the | t filter and the translate() helper in compute.js fall back through: requested locale &rarr; English &rarr; raw key.

Translation files are mtime-cached &mdash; edits are picked up on the next request with no restart.

9. styles.css &mdash; custom styles

Location: print-templates/<folder>/styles.css

If this file exists, its contents are injected as an inline <style> block into the rendered HTML (inside <head> if present, otherwise prepended). Use it to override base layout styles for a specific template without modifying _base/*.html.

10. Worked example &mdash; Registration card

data-config.json

Declares the three API calls. No transformation here.

{
  "sources": {
    "patient": {
      "api": "fhir",
      "resource": "Patient",
      "params": { "_id": "{{patientUuid}}" }
    },
    "relatives": {
      "api": "fhir",
      "resource": "RelatedPerson",
      "params": { "patient": "{{patientUuid}}" }
    },
    "patientProfile": {
      "api": "rest",
      "resource": "/openmrs/ws/rest/v1/patientprofile/{{patientUuid}}",
      "params": { "v": "full" }
    }
  }
}

compute.js

Extracts all fields from resolved sources.

module.exports = {
  compute: async function ({ context, data, resolved, ValidationError }) {
    const patient  = resolved?.patient?.entry?.[0]?.resource;
    const relative = resolved?.relatives?.entry?.[0]?.resource;
    const profile  = resolved?.patientProfile;

    const officialId = patient?.identifier?.find((id) => id.use === 'official');

    return {
      patientId:             officialId?.value ?? '',
      patientName:           patient?.name?.[0]?.text ?? '',
      birthDate:             patient?.birthDate ?? '',
      age:                   computeAge(patient?.birthDate),
      gender:                patient?.gender ?? '',
      phone:                 patient?.telecom?.find((t) => t.system === 'phone')?.value ?? '',
      address:               patient?.address?.[0]?.text ?? '',
      village:               patient?.address?.[0]?.city ?? '',
      tehsil:                patient?.address?.[0]?.district ?? '',
      registrationDate:      profile?.patient?.auditInfo?.dateCreated ?? '',
      nextOfKinName:         relative?.name?.[0]?.text ?? '',
      nextOfKinRelationship: relative?.relationship?.[0]?.text ?? '',
      photoUrl:              `/openmrs/ws/rest/v1/patientImage?patientUuid=${context.patientUuid}`,
    };
  },
};

function computeAge(birthDate) {
  if (!birthDate) return '';
  const birth  = new Date(birthDate);
  const now    = new Date();
  const days   = Math.floor((now - birth) / (1000 * 60 * 60 * 24));
  if (days < 30)   return `${days} days`;
  const months = Math.floor(days / 30.44);
  if (months < 12) return `${months} months`;
  return `${Math.floor(months / 12)} years`;
}

template.html

Every value comes from {{ computed.* }}.

{% extends "_base/landscape.html" %}
{% block content %}
<table class="registrationCard-details">
  <tr>
    <td>{{ 'DATED' | t }} :</td>
    <td>{{ computed.registrationDate | dateFormat | default('') }}</td>
  </tr>
  <tr>
    <td>{{ 'REGISTRATION' | t }} :</td>
    <td>{{ computed.patientId | default('') }}</td>
  </tr>
  <tr>
    <td>{{ 'NAME' | t }} :</td>
    <td>{{ computed.patientName | default('') }}</td>
  </tr>
  <tr>
    <td>{{ 'AGE' | t }} :</td>
    <td>{{ computed.age | default('') }}</td>
  </tr>
  <tr>
    <td>{{ 'GENDER' | t }} :</td>
    <td>{{ computed.gender | upper | first | default('') }}</td>
  </tr>
  <tr>
    <td>{{ 'ADDRESS' | t }} :</td>
    <td>{{ computed.address | default('') }}</td>
  </tr>
  <tr>
    <td>{{ 'VILLAGE' | t }} :</td>
    <td>{{ computed.village | default('') }}</td>
  </tr>
  <tr>
    <td>{{ 'TEHSIL' | t }} :</td>
    <td>{{ computed.tehsil | default('') }}</td>
  </tr>
</table>
{% if computed.photoUrl %}
  <img src="{{ computed.photoUrl }}" style="width:20mm;height:20mm;"
       onerror="this.style.display='none'" />
{% endif %}
{% endblock %}

11. Worked example &mdash; Prescription

data-config.json

{
  "sources": {
    "patient": {
      "api": "fhir",
      "resource": "Patient",
      "params": { "_id": "{{patientUuid}}" }
    },
    "medicationRequests": {
      "api": "fhir",
      "resource": "MedicationRequest",
      "params": {
        "patient": "{{patientUuid}}",
        "_include": ["MedicationRequest:encounter", "MedicationRequest:medication"],
        "_sort": "-_lastUpdated",
        "_count": "100"
      }
    }
  }
}

compute.js

Validates inputs, extracts patient fields, and groups medications by encounter.

module.exports = {
  compute: async function ({ context, resolved, data, ValidationError }) {
    if (!context?.patientUuid) throw new ValidationError('patientUuid is required');
    if (!context?.visitUuid)   throw new ValidationError('visitUuid is required');

    const patient    = resolved?.patient?.entry?.[0]?.resource;
    const officialId = patient?.identifier?.find((id) => id.use === 'official');

    const entries = resolved?.medicationRequests?.entry ?? [];
    const byType  = (type) => entries.filter(e => e.resource?.resourceType === type).map(e => e.resource);

    const encounterResources = byType('Encounter');
    const visitRef           = `Encounter/${context.visitUuid}`;
    const visitEncounterIds  = new Set(
      encounterResources
        .filter(enc => enc.partOf?.reference === visitRef)
        .map(enc => enc.id),
    );
    const encounterMap = new Map(encounterResources.map(e => [e.id, e]));

    const byEncounter = new Map();
    for (const mr of byType('MedicationRequest')) {
      const encId = mr.encounter?.reference?.split('/')?.[1] ?? '';
      if (!visitEncounterIds.has(encId)) continue;
      if (!byEncounter.has(encId)) byEncounter.set(encId, []);
      byEncounter.get(encId).push(mr);
    }

    const firstStart = [...byEncounter.keys()]
      .map(id => encounterMap.get(id)?.period?.start)
      .find(Boolean);

    const encounters = [...byEncounter.entries()].map(([encId, meds]) => ({
      doctorName: encounterMap.get(encId)?.participant?.[0]?.individual?.display ?? '',
      drugOrders: meds.map(mr => {
        const stopped = ['stopped', 'cancelled'].includes(mr.status);
        return {
          drugName:           mr.medicationCodeableConcept?.text ?? '',
          dosageInstructions: buildDosageInstructions(mr.dosageInstruction),
          startDate:          formatDate(mr.authoredOn ?? ''),
          stopped,
          stoppedDate:        stopped ? formatDate(mr.dispenseRequest?.validityPeriod?.end ?? '') : '',
          treatmentNotes:     mr.note?.[0]?.text ?? '',
        };
      }),
    }));

    return {
      patientName: patient?.name?.[0]?.text ?? '',
      patientId:   officialId?.value ?? '',
      age:         computeAge(patient?.birthDate),
      gender:      patient?.gender ?? '',
      village:     patient?.address?.[0]?.city ?? '',
      district:    patient?.address?.[0]?.district ?? '',
      visitDate:   firstStart ? formatDate(firstStart) : '',
      encounters,
    };
  },
};

function computeAge(birthDate) {
  if (!birthDate) return '';
  const days = Math.floor((new Date() - new Date(birthDate)) / (1000 * 60 * 60 * 24));
  if (days < 30)   return `${days} days`;
  const months = Math.floor(days / 30.44);
  if (months < 12) return `${months} months`;
  return `${Math.floor(months / 12)} years`;
}

function buildDosageInstructions(dosageInstruction) {
  const d = dosageInstruction?.[0];
  if (!d) return '';
  const parts = [];
  const doseQty = d.doseAndRate?.[0]?.doseQuantity;
  if (doseQty?.value != null) parts.push(`${doseQty.value} ${doseQty.unit ?? ''}`.trim());
  const frequency = d.timing?.code?.text;
  if (frequency) parts.push(frequency);
  if (d.asNeededBoolean) parts.push('SOS');
  const route = d.route?.text;
  if (route) parts.push(route);
  const repeat = d.timing?.repeat;
  if (repeat?.duration != null) return `${parts.join(', ')} - ${repeat.duration} ${durationLabel(repeat.durationUnit)}`;
  return parts.join(', ');
}

function durationLabel(code) {
  const map = { s: 'Second(s)', min: 'Minute(s)', h: 'Hour(s)', d: 'Day(s)', wk: 'Week(s)', mo: 'Month(s)', a: 'Year(s)' };
  return map[code] ?? code ?? '';
}

function formatDate(value) {
  if (!value) return '';
  try {
    const date = new Date(value);
    if (isNaN(date.getTime())) return '';
    return `${String(date.getDate()).padStart(2, '0')} ${date.toLocaleDateString('en', { month: 'long' })} ${date.getFullYear()}`;
  } catch { return ''; }
}

template.html

htmlwide760

Name: {{ computed.patientName | default('') }}

Age: {{ computed.age | default('') }} ({{ computed.gender | capitalize | default('') }})

Registration No: {{ computed.patientId | default('') }}

Village: {{ computed.village | default('') }}

District: {{ computed.district | default('') }}

Visit Date: {{ computed.visitDate | default('') }}

{% for encounter in computed.encounters %}

Consultation with {{ encounter.doctorName | default('') }}

S. No.Drug NameDosage InstructionsStart Date

{% for drug in encounter.drugOrders %}

{{ loop.index }}.

{{ drug.drugName }}

{{ drug.dosageInstructions | default('—') }}

{% if drug.stopped and drug.stoppedDate %}

stopped {{ drug.stoppedDate }}

{% endif %}

{{ drug.startDate | default('') }}

{% if drug.treatmentNotes %}

Treatment Notes: {{ drug.treatmentNotes }}

{% endif %}

{% endfor %}

{% else %}

No medications prescribed for this visit.

{% endfor %}

{% endblock %}]]>

{% extends "_base/portrait.html" %}
{% block content %}

<div class="patient-info">
  <table>
    <tr>
      <td>Name: {{ computed.patientName | default('') }}</td>
      <td>Age: {{ computed.age | default('') }} ({{ computed.gender | capitalize | default('') }})</td>
      <td>Registration No: {{ computed.patientId | default('') }}</td>
    </tr>
    <tr>
      <td>Village: {{ computed.village | default('') }}</td>
      <td>District: {{ computed.district | default('') }}</td>
      <td>Visit Date: {{ computed.visitDate | default('') }}</td>
    </tr>
  </table>
</div>

{% for encounter in computed.encounters %}
<h3>Consultation with {{ encounter.doctorName | default('') }}</h3>
<table class="prescription-table">
  <tr>
    <th>S. No.</th><th>Drug Name</th><th>Dosage Instructions</th><th>Start Date</th>
  </tr>
  {% for drug in encounter.drugOrders %}
  <tbody>
    <tr>
      <td>{{ loop.index }}.</td>
      <td class="{{ 'strike-text' if drug.stopped }}">{{ drug.drugName }}</td>
      <td>
        <span class="{{ 'strike-text' if drug.stopped }}">
          {{ drug.dosageInstructions | default('—') }}
        </span>
        {% if drug.stopped and drug.stoppedDate %}
          <span>stopped {{ drug.stoppedDate }}</span>
        {% endif %}
      </td>
      <td>{{ drug.startDate | default('') }}</td>
    </tr>
    {% if drug.treatmentNotes %}
    <tr>
      <td></td><td></td>
      <td colspan="2"><strong>Treatment Notes:</strong> {{ drug.treatmentNotes }}</td>
    </tr>
    {% endif %}
  </tbody>
  {% endfor %}
</table>
{% else %}
<p>No medications prescribed for this visit.</p>
{% endfor %}

{% endblock %}

12. Passing caller data into a template

The data field in the render request lets the caller inject arbitrary values directly into the template and compute.js &mdash; without any OpenMRS API call.

Request:

{
  "templateId": "PRESCRIPTION_V1",
  "locale": "en",
  "context": { "patientUuid": "...", "visitUuid": "..." },
  "data": {
    "printedBy": "Dr. Smith",
    "showWatermark": true
  }
}

In compute.js:

compute: async function ({ context, resolved, data, ValidationError }) {
  return {
    printedBy: data?.printedBy ?? '',
    // ...
  };
}

In template.html:

{% if data.showWatermark %}<div class="watermark">DRAFT</div>{% endif %}
<p>Printed by: {{ data.printedBy | default('') }}</p>

13. Testing a template

After creating or editing a template, test it with curl using a real JSESSIONID (DevTools &rarr; Network &rarr; any request &rarr; Request Headers &rarr; Cookie):

curl -s -X POST http://localhost:8080/template-service/api/render \
  -H "Content-Type: application/json" \
  -H "Cookie: JSESSIONID=<your-session-id>" \
  -d '{
    "templateId": "REG_CARD_V1",
    "format": "html",
    "locale": "en",
    "context": { "patientUuid": "<real-patient-uuid>" }
  }' > /tmp/rendered.html && open /tmp/rendered.html

Verify barcodes and QR codes are present:

grep -c 'data:image/png;base64' /tmp/rendered.html   # barcode count
grep -c '<svg' /tmp/rendered.html                     # qrcode count

Check the service logs for errors:

docker logs bahmni-standard-template-service-1 --tail 30

List all registered templates:

curl -s http://localhost:8080/template-service/api/templates | jq .

14. Common pitfalls

Putting a computed block in data-config.json

data-config.json is sources only. All field extraction belongs in compute.js.

// Wrong
{ "sources": { ... }, "computed": { ... } }

// Correct
{ "sources": { ... } }

Using rest with a short path

For rest sources, always provide the full path from the domain root.

// Wrong
{ "api": "rest", "resource": "patientprofile/{{patientUuid}}" }

// Correct
{ "api": "rest", "resource": "/openmrs/ws/rest/v1/patientprofile/{{patientUuid}}" }

Not handling empty bundles

// Unsafe
const patient = resolved.patient.entry[0].resource;

// Safe
const patient = resolved?.patient?.entry?.[0]?.resource;

Returning an array instead of an object from compute.js

// Wrong
return ['a', 'b'];

// Correct
return { items: ['a', 'b'] };

QR code in a separate table column

<!-- Wrong: extra column gets pushed off the visible card area -->
<td>{{ computed.patientId }}</td>
<td>{{ computed.patientId | qrcode(80) }}</td>

<!-- Correct: inline in the same cell -->
<td>
  {{ computed.patientId | default('') }}<br>
  {{ computed.patientId | qrcode(80) }}
</td>

Using | barcode('qrcode', ...) instead of | qrcode

<!-- Wrong -->
{{ computed.patientId | barcode('qrcode', 80) }}

<!-- Correct -->
{{ computed.patientId | qrcode(80) }}             {# QR code (SVG) #}
{{ computed.patientId | barcode('code128', 40) }} {# traditional barcode (PNG) #}

Throwing a plain Error for input validation

// Wrong — causes a 500 instead of 400
throw new Error('visitUuid is required');

// Correct
throw new ValidationError('visitUuid is required');

15. API reference

POST /template-service/api/render

Request body:

{
  "templateId": "PRESCRIPTION_V1",
  "format": "html",
  "locale": "en",
  "context": {
    "patientUuid": "ae94ad73-ac02-42ab-aa04-fe658fb7c091",
    "visitUuid": "14ee47a3-b093-4e0e-b718-d68fcf2666ab"
  },
  "data": {
    "printedBy": "Dr. Smith"
  }
}
FieldRequiredDefaultDescription
templateIdYes&mdash;ID from templates.json
formatNo"html"Only "html" is supported
localeNo"en"BCP 47 locale tag for translations and date formatting
contextNo{}UUIDs passed to data-config.json placeholders and compute.js
dataNo{}Free-form object available as {{ data.* }} in templates and data in compute.js

Response: { "html": "<rendered HTML string>" }

GET /template-service/api/templates

Returns all registered templates.

Response: { "templates": [{ "id": "...", "name": "..." }] }

GET /template-service/health

Response: { "status": "ok", "timestamp": "..." }

Authentication

Pass the user's OpenMRS session using one of:

HeaderValue
CookieJSESSIONID=<session-id>
X-OpenMRS-Session-Id<session-id>
X-OpenMRS-AuthorizationBasic <base64>

16. Environment variables

VariableDefaultDescription
OPENMRS_URL<http://openmrs:8080>OpenMRS instance base URL
OPENMRS_TIMEOUT_MS10000API request timeout in milliseconds
TEMPLATES_DIR/etc/bahmni_config/print-templatesTemplate directory mount path
PORT8080Service HTTP port
LOG_LEVELinfoLog level (trace, debug, info, warn, error, fatal)
원문 정보

Bahmni Wiki · CC BY-SA 4.0

원문 보기 ↗