How to create a Domain-Specific Language in Elixir to render templates for Slack, Microsoft Teams, and beyond

ELIXIR PROTOCOLS TEMPLATES

Cassava was designed from the outset to publish Experiences to multiple distribution channels. Today I will discuss how we are leveraging our initial architectural implementation for Slack to support the latest integration with Microsoft Teams.

I highly recommend you review the earlier article on Using the Protocol Pattern in Elixir to render Templates, as it sets the foundation for today’s discussion.

The gist of the article is that Cassava uses a rendering engine to generate content specific to a distribution channel via the protocol pattern.

Render = Template + Data

I elaborated on the rendering engine in the article but skipped the details of the templating semantics, which is the subject of this topic.

Slack Block Builder

On day 1, I approached Cassava’s Slack integration with a Domain-Specific language (DSL) in mind.

Slack introduced Block Kit in 2019 to streamline composing messages for delivery by Bots. The framework provides several block elements for the creation of rich interactive layouts. Messages are specified as JSON and published to workspaces via the Web API.

Here is an example of a typical block structure in Cassava.

{
  "blocks": [
    {
      "text": {
        "text": "*Compliance Training 101*",
        "type": "mrkdwn"
      },
      "type": "section"
    },
    {
      "text": {
        "text": ">Welcome to our annual compliance training. New employees are
          expected to complete this training within the first week ...",
        "type": "mrkdwn"
      },
      "type": "section"
    },
    ...
}

The Origins of our Domain-Specific Language

It was clear from the beginning that specifying templates at this level of granularity would be a maintenance challenge as we grew our distribution channel footprint.

Each small change to a block’s specification would require manually updating all the templates.

A case in point is the hero block. We began with a plain text block with a bold markdown as above. Later, Slack introduced the more appealing header block, which required changing the type and markdown.

{
  "text": {
    "text": "Compliance Training 101",
    "type": "mrkdwn"
  },
  "type": "header"
}

Making this small change in multiple JSON templates would be time-consuming and error-prone.

I thought it best to describe the templates at a higher level of abstraction and allow the Slack Block Kit framework nuances to be later substituted.

Good to know, Michael, but how does it look?

Templates Assemble

The introduction of a DSL allows us to represent a template as a list of key-value tuples. Let’s see what this looks like when applied to the sample template introduced earlier.

def render(channel, %Data{} = data) do
 builder =
   case channel do
     :slack -> Cassava.SlackBlockBuilder
     :teams -> Cassava.TeamsAdaptiveCardBuilder
    end

  [
    {:experience_name,
     builder.build(
       :header,
        data.experience_name
     )},
    {:description,
     builder.build(
       :text,
        data.description
     , %{quote: true})},
     ...
  ]
end

Here we have two DSL blocks, one for the header section and the other for a plain text section with a blockquote.

I will follow up on the importance of the tuple’s key. What is essential to note at this stage is the clear separation of content and layout.

data = %{
  experience_name: "Compliance Training 101",
  description: "Welcome to our annual compliance training. New employees are
          expected to complete this training within the first week ..."
}

And what about the channel parameter?

The channel property allows a template to be transformed into a message appropriate to a platform, be it Slack today, Microsoft Teams tomorrow, or a host of others to follow, each abiding by the DSL behavior.

Build Functions

The DSL is a behavior that defines several build functions for each supported component.

defmodule Cassava.Builder do
  @type component ::
          {
            :header
            | :section
            | :button
            | :hyperlink
            | :image
            | :input
            ...
          }

  @callback build(kind :: component) :: map()
  @callback build(kind :: component, arg :: term()) :: map()
  @callback build(kind :: component, arg1 :: term(), arg2 :: term()) :: map()
  ...

end

The Slack Block Kit implementations for the header and plain text sections are below.

defmodule Cassava.SlackBlockBuilder do
  @behaviour Cassava.Builder

  def build(:header, title) when is_bitstring(title) do
    %{
      type: "header",
      text: %{
        type: "plain_text",
        text: title,
        emoji: true
      }
    }
  end

  def build(:section, text, opts) when is_bitstring(text) and is_map(opts) do
    %{
      type: "section",
      text: %{
        type: "mrkdwn",
        text: decorate_text(text, opts)
      }
    }
  end

  ...

  defp decorate_text(text, %{quote: true} = opts), do:
    decorate_text(">" <> String.replace(text, "\n", "\n>"), Map.delete(opts, :quote))

  defp decorate_text(text, _), do: text

end

Wrapping each Slack block element in the DSL allows the templates to be specified at a higher level of abstraction for ease of use and maintenance.

Great, can we see a preview of the same for Microsoft Teams?

Yes indeed.

Microsoft Teams’ answer to Slack Block Kit is their Adaptive Card framework.

The idea is the same as before: we will define a DSL to wrap the card elements.

defmodule Cassava.TeamsAdaptiveCardBuilder do
  @behaviour Cassava.Builder

  def build(:header, text, opts) when is_bitstring(text) and is_map(opts),
    _build(
      :text,
      text,
      opts |> Map.merge(%{bold: true, size: :large, subtle: false, alignment: :left})
    )

  def build(:section, text, opts) when is_bitstring(text) and is_map(opts),
    do: _build(:text, text, opts)

  defp _build(:text, text, opts) when is_bitstring(text) and is_map(opts) do
    %{
      type: "TextBlock",
      text: text
    }
    |> decorate_textblock(opts)
  end

  defp decorate_textblock(content, opts) do
    content
    |> Map.merge(%{
      size: opts[:size] || :default,
      spacing: opts[:spacing] || :default,
      weight: set_weight(opts),
      isSubtle: opts[:subtle] || false,
      color: opts[:color] || :default,
      fontType: opts[:font] || :default,
      wrap: true,
      separator: opts[:separator] || false,
      alignment: opts[:alignment] || :left
    })
    |> decorate(opts, :lines, :maxLines)
    |> decorate(opts, :quote)

  defp decorate(block, opts, :quote) do
    # fake a blockquote as one is not supported
    opts[:quote]
    |> case do
      true ->
        _build(:columns, [
          %{items: [], opts: [width: :auto, spacing: :medium, style: :default]},
          %{
            items: [block |> Map.merge(%{isSubtle: true, color: :good})],
            opts: [
              separator: true,
              style: :default,
              width: :stretch,
              spacing: :medium
            ]
          }
        ])

      _ ->
        block
    end

  defp _build(:columns, [%{items: _, opts: _} | _] = columns)
       when is_list(columns)
       when length(columns) <= 2 do
    %{
      type: "ColumnSet",
      spacing: :default,
      columns:
        columns
        |> Enum.map(
          &(%{
              type: "Column",
              items: &1.items,
              width: &1[:opts][:width] || :stretch,
              horizontalAlignment: &1[:opts][:horizontal_alignment] || :left,
              verticalContentAlignment: &1[:opts][:vertical_alignment] || :center,
              separator: &1[:opts][:separator] || false,
              style: &1[:opts][:style] || :emphasis,
              spacing: &1[:opts][:spacing] || :none
            }
        )
    }
end

The Adaptive Card specifications are much more verbose; nonetheless, the template remains unchanged and is invoked with the :teams channel value.

By now, the power of the DSL as the approach to templating multiple distribution channels should be evident. The sharp reduction in maintenance and common language of use justifies the upfront time required to implement the contract for each channel.

I did promise one more thing, right?

Let’s revisit the importance of the Tuple’s key.

The DSL approach did not remove all the complexity from the domain.

Initially, I built the list of block elements in-line, using each data item’s value to drive the inclusion and position of a block within the list. As you can imagine, this approach quickly became unwieldy and made the code hard to follow.

This code smell led me to conceive a more optimum approach that uses a key-value tuple.

template  = [
  {:experience_name, ...},
  {:description, ...}
]

A two-step rendering process follows in which the first pass involves a complete rendering of the template. A second pass sets the visibility of each block based on the underlying data itself.

channel
|> render(data)
|> prune(data)

defp prune(content, data, [key | remainder]) do
  data
  |> Map.get(key)
  |> case do
    value when value in [nil, false] -> content |> Keyword.delete(key)
    _ -> content
  end
  |> prune(data, remainder)
end

defp prune(content, _, []), do: content

Summary

So there you have it. Creating a homegrown DSL to reduce complexity makes it a breeze to extend Cassava to future distribution channels in a structured manner.

Have you implemented a DSL to solve a problem you faced, or are you thinking of doing likewise? Share your experience or questions in the comments below.

Your feedback is welcomed as always.

Michael


You no doubt have an opinion bubbling to the surface.
Let's go one step farther and add your voice to the conversation.
Your email is used to display your Gravatar and is never disclosed. As always, do review our moderation guidelines to keep the converstion friendly and respectful.