Django: A version of json_script for pre-serialized JSON strings

Copy-pasting code kinda makes a little baby Django!

Django’s json_script template filter is a convenient and safe way to pass a larger amount of data to JavaScript. I covered it in my post last year How to Safely Pass Data to JavaScript in a Django Template.

I found an interesting use case for json_script in my client Silvr’s project. The view had a pandas DataFrame that needed passing through the template to a chart-drawing JavaScript function. The default json_script wouldn’t work with pandas’ JSON output, so I created a custom version.

pandas provides DataFrame.to_json() for converting a DataFrame to a JSON string. This method is convenient but its output is still not safe against HTML injection—it needs the escaping that json_script performs. But json_script only accepts an object to turn into a JSON string, and then escape - it cannot operate on a pre-serialized JSON string.

You could add the escaping with a chain of:

  1. DataFrame.to_json to convert to a JSON string
  2. json.loads() to unserialize the result
  3. json_script on the result, to re-serialize and escape the result

But the repeated serialization is wasteful and would take non-negligible time with large data.

Rather than do that, I made a custom template filter that was a modified copy of json_script with no serialization step:

from django import template
from django.utils.html import format_html
from django.utils.safestring import mark_safe


register = template.Library()


_json_script_escapes = {
    ord(">"): "\\u003E",
    ord("<"): "\\u003C",
    ord("&"): "\\u0026",
}


@register.filter(is_safe=True)
def preserialized_json_script(json_string: str, element_id: str | None = None) -> str:
    """
    Output value JSON-encoded, wrapped in a <script type="application/json">
    tag (with an optional id).

    A version of Django’s json_script that works with data that has already
    been dumped to JSON, for example from pandas.DataFrame.to_json()
    """
    json_str = json_string.translate(_json_script_escapes)
    args: tuple[str, ...]
    if element_id:
        template = '<script id="{}" type="application/json">{}</script>'
        args = (element_id, mark_safe(json_str))
    else:
        template = '<script type="application/json">{}</script>'
        args = (mark_safe(json_str),)
    return format_html(template, *args)

Compare this to the upstream source and you’ll see it’s the same but without the json.dumps() serialization call.

To use this tag, the view passes the serialized JSON string in the template context:

import pandas
from django.shortcuts import render


def dashboard(request):
    chart_df = get_chart_data()

    return render(
        request,
        "dashboard.html",
        {
            "chart_data": chart_df.to_json(orient="index"),
        },
    )


def get_chart_data(): ...

And the template then uses the filter:

{% load example_tags %}

...

<div id=chart>
  ...
  {{ chart_data|preserialized_json_script }}
</div>

I wrote tests for the filter, to make sure the escaping worked properly:

import pandas

from example.templatetags.example_tags import preserialized_json_script
from django.test import SimpleTestCase


class PreserializedJsonScriptTests(SimpleTestCase):
    def test_simple_no_element_id(self) -> None:
        result = preserialized_json_script("{}")

        assert result == r'<script type="application/json">{}</script>'

    def test_simple_with_element_id(self) -> None:
        result = preserialized_json_script("{}", "my-script")

        assert result == r'<script id="my-script" type="application/json">{}</script>'

    def test_pandas(self) -> None:
        df = pandas.DataFrame(
            {"ID": [1, 2], "Name": ["&ref", "<batman>"]},
        )
        json_string = df.to_json(orient="index")

        result = preserialized_json_script(json_string)

        assert result == (
            '<script type="application/json">'
            + '{"0":{"ID":1,"Name":"\\u0026ref"},'
            + '"1":{"ID":2,"Name":"\\u003Cbatman\\u003E"}}'
            + "</script>"
        )

This filter was only used in a couple of places in the project. If there were many places where DataFrames were passed to JavaScript in the same way, I would consider creating a specialized DataFrame-to-JSON-script filter.

Fin

I hope this serves as an example of how to copy-paste and adapt code from Django when you need to. It’s not that scary, and tests help you ensure it works as intended!

May your copy-pasting always be done with understanding,

—Adam


😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: ,