Nunjucks templating explained on the basis of AsyncAPI specification

Lukasz Gornicki

Lukasz Gornicki

·7 min read

Edit 14.04.2021

In this post, I explain how you can use Nunjucks to template information extracted from an AsyncAPI file. I also write how you can make it even easier using Nunjucks inside the AsyncAPI Generator. Now, we also have a React-based render engine inside the generator, and it is far more developer-friendly. I encourage you to try it out.

Specifications exist for a reason. Among other things, they help to bring quality, consistency, and standardize a given area. They are a great use case for templating engines. You can prepare a template that generates something from any document that follows a particular specification. You can generate whatever you want, docs, code, and diagrams. The sky is the limit.

Templating is a vast topic that is impossible to cover in a single post. In JavaScript alone, there is a zoo of different templating engines. This is why I focus here only on one engine for JavaScript, which is Nunjucks. Why? Soon you'll figure that out.

tl;dr In case you don't want to read and prefer to jump right into code. Go to this CodeSandbox project, but keep in mind you'll miss the important context and explanation. Edit learning-nunjucks

What is AsyncAPI?

AsyncAPI is a specification that you use to create machine-readable definitions of your event-driven APIs:

  • It focuses on the application from the API user perspective. You describe what the user can do with the API, subscribe or publish to it.
  • It is protocol-agnostic so that you can use it for APIs using Kafka or MQTT, and many others.
  • It supports many different schema formats, so you can describe messages payload schema in a format that you already use like, for example, Avro.

What is Nunjucks?

Nunjucks is a templating engine for JavaScript, inspired by Jinja. It has many nifty features that make templating really nice:

  • Variables declaration
  • Built-in filters
  • Way to create custom filters
  • Chaining filters
  • Includes
  • Macros

Nunjucks basics by example

All examples shown in this post can be explored in action in below CodeSandbox project.

In this learning project, I created a simple Express app that handles super short documentation generated from the AsyncAPI file. It is just a small sample of things that you can get from AsyncAPI using Nunjucks.

I picked Nunjucks here for a reason. AsyncAPI community maintains a tool for generating different things from the specification document, and it is using Nunjucks as a templating engine. This basically means, use my CodeSandbox to experiment with Nunjucks, but if you plan to build some serious template for AsyncAPI, do it with the generator or reuse existing templates.

Variables declaration

You can declare inside the template a variable, that helps you in cases like loops. Their great use case is the same as in programming. If you have a value that you use more than once, assign it to a variable.

I used it to keep the name of the API:

{% set apiName = asyncapi.info().title() %}

Then I could use it multiple times, for example in these sentences:

1{/* Sentence 1 */}
2The {{ apiName }} is licensed under {{ asyncapi.info().license().name() }}.
3
4{/* Sentence 2 */}
5<p>
6  Here you can find a list of channels to which you can publish and
7  <strong>{{ apiName }}</strong> is subscribed to:
8</p>

Built-in filters

Unlike other engines, Nunjucks comes with many built-in helpers, called filters. There are around 40 different. You can for example easily make a value all uppercase:

1{/* server.protocol() value comes as all lowercase */}
2using {{ server.protocol() | upper }} protocol

Creating custom filters

Built-in filters are awesome, but sometimes you need to create your filters. In my example, I had to build a filter that helps me to modify the server.url() value.

In the AsyncAPI document, you can specify a server that the application uses to publish and consume messages from. In the URL, you are allowed to use variables like this: test.mosquitto.org:{port}. Such a variable can be described with different levels of detail. You can provide a default value and even an enum of values.

In my example, instead of a URL like test.mosquitto.org:{port}, I wanted to get a fixed URL with a proper port number taken from the document:

1//replace is performed only if there are variables in the URL and they are declared for a server
2function replaceVariablesWithValues(url, serverVariables) {
3  const urlVariables = getVariablesNamesFromUrl(url);
4  const declaredVariables = urlVariables.filter((el) =>
5    serverVariables.hasOwnProperty(el[1])
6  );
7
8  if (urlVariables.length !== 0 && declaredVariables.length !== 0) {
9    let value;
10    let newUrl = url;
11
12    urlVariables.forEach((el) => {
13      value = getVariableValue(serverVariables, el[1]);
14
15      if (value) {
16        newUrl = newUrl.replace(el[0], value);
17      }
18    });
19    return newUrl;
20  }
21  return url;
22}
23
24function getVariablesNamesFromUrl(url) {
25  let result = [],
26    array;
27  const regEx = /{([^}]+)}/g;
28
29  while ((array = regEx.exec(url)) !== null) {
30    result.push([array[0], array[1]]);
31  }
32
33  return result;
34}
35
36function getVariableValue(object, variable) {
37  const keyValue = object[variable]._json;
38
39  if (keyValue) return keyValue.default || (keyValue.enum && keyValue.enum[0]);
40}

Such a filter is very handy to use, the same as the built-in filters. You can additionally enrich its context. Take a look below where you can see that my filter gets not only server.url() value as a context but also server.variables():

{{ server.url() | replaceVariablesWithValues(server.variables()) }}

Chaining filters

Built-in filters, custom filters...that is not all. Chaining of the filters is like an icing on the cake.

The same case with URL. The URL after replacing variables with values, I want to transform it into a clickable element and make it part of the DOM. All of it made easy thanks to chaining:

1{{ server.url() | replaceVariablesWithValues(server.variables()) | urlize | safe
2}}

Includes

You can share static parts of the template. This allows you to decrease the size of templates and make maintenance easier. My example here is not very complex, and I've added it to the template to make the point that it is possible:

1{/* content of space.html file */}
2<hr />
3<br />

I can include it as many times as I want across the templates like this:

{% include "space.html" %}

Macros

You can share not only static but also dynamic parts of the template. What does it mean? Let's take an HTML list as an example. From the syntax/structure perspective, it always looks the same, but the displayed values of the list are different. Macros are here to help you out to define a list element once. It is like a mixture of the include and a filter.

In the AsyncAPI document, I have a case where I want to list all the channels that the application uses. Actually, I want to have two lists: one list that has channels where the application is subscribed (publish operation) to receive messages and the other one where the application publishes (subscribe operation) messages to.

First you define a macro:

1{% macro listEl(value) %}
2<li><strong>{{ value }}</strong></li>
3{% endmacro %}

Then you can import macros in your template:

{% import "macros.html" as helpers %}

You call macros like you typically call functions:

{{ helpers.listEl(channelName) }}

Conclusion

Don't build tools from scratch if there are others already available, and they are open for contributions. Trying something from scratch, as I did with the templating CodeSandbox for AsyncAPI, makes sense only for learning purposes.

Keep in mind that AsyncAPI is an open community. We do not work on the specification only, but tools too. Join us on Slack and help us build awesome tools or donate.

Take time to look into the parser-js. I used it in my CodeSandbox to parse the AsyncAPI document to pass it to templates as a context.