Server-side HTML with Thymeleaf

Learn how to render an HTML page with Thymeleaf and Micronaut Views

1. Getting Started

In this guide, we will create a Python application built with Pyronaut.

2. What you will need

To complete this guide, you will need the following:

  • Some time on your hands

  • GraalPy installed and the Pyronaut CLI available locally

3. Solution

We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.

4. Writing the Application

Create an application using the Pyronaut CLI (Command Line Interface) or Pyronaut Launch

pyronaut create example.micronaut.micronautguide --features=views-thymeleaf,http-client

5. Views

To use the Thymeleaf Java template engine to render views in a Pyronaut application, add the following dependency on your classpath.

5.1. API Response

For this guide, we will use a publicly available REST API jsonplaceholder.typicode.com as a 3rd party Photo API.

Create a Photo type to map the API response:

src/example/micronaut/photo.py
from dataclasses import dataclass

from micronaut.serde.annotation import Serdeable

@Serdeable  (1)
@dataclass
class Photo:
    albumId: int
    title: str
    url: str
    thumbnailUrl: str
1 Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized.

Thymeleaf accesses Photo via reflection. By adding the @ReflectiveAccess annotation, the application enables reflective access to the Photo type.

5.2. HTTP Client

src/example/micronaut/photos_client.py
from abc import ABC, abstractmethod
from typing import Annotated

from micronaut.http.annotation import Get, PathVariable
from micronaut.http.client.annotation import Client

from .photo import Photo


@Client(id="photos")  (1)
class PhotosClient(ABC):

    @Get("/photos/{id}")  (2)
    @abstractmethod
    def find_by_id(self, id: Annotated[int, PathVariable]) -> Photo:  (3)
        ...
1 Use @Client to use declarative HTTP Clients. You can annotate interfaces or abstract classes. You can use the id member to provide a service identifier or specify the URL directly as the annotation’s value.
2 The @Get annotation maps the findById method to an HTTP GET request on /photos/{id}.
3 You can define path variables with a RFC-6570 URI template in the HTTP Method annotation value. The method argument can optionally be annotated with @PathVariable.

5.3. HTTP Client Configuration

We used photos as the HTTP Client identifier.

Configure the micronaut.http.services.photos.url to point the client identifier to jsonplaceholder.typicode.com

config/application.toml
[micronaut.http.services.photos]
url = "https://jsonplaceholder.typicode.com"

5.4. Controller

Create a controller, which invokes the declarative HTTP client:

src/example/micronaut/photos_controller.py
from typing import Annotated

from micronaut.http import MediaType
from micronaut.http.annotation import Controller, Get, PathVariable
from micronaut.scheduling import TaskExecutors
from micronaut.scheduling.annotation import ExecuteOn
from micronaut.views import View

from .photo import Photo
from .photos_client import PhotosClient


@Controller("/photos")  (1)
class PhotosController:
    def __init__(self, photos_client: PhotosClient):  (2)
        self.photos_client = photos_client

    @View("photos/show.html")  (5)
    @Get(value="/{id}", produces=MediaType.TEXT_HTML)   (3) (6)
    @ExecuteOn(TaskExecutors.BLOCKING)  (4)
    def find_by_id(self, id: Annotated[int, PathVariable]) -> dict[str, Photo]:  (7)
        return {"photo": self.photos_client.find_by_id(id)}
1 The class is defined as a controller with the @Controller annotation mapped to the path /photos.
2 Use constructor injection to inject a bean of type PhotosClient.
3 Set the response content-type to HTML with the @Produces annotation.
4 It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop.
5 Use View annotation to specify which template to use to render the response.
6 The @Get annotation maps the findById method to an HTTP GET request on /photos/{id}.
7 You can define path variables with a RFC-6570 URI template in the HTTP Method annotation value. The method argument can optionally be annotated with @PathVariable.

5.5. Thymeleaf template

Create a Thymeleaf template in config/views/photos/show.html:

config/views/photos/show.html
<!DOCTYPE html>
<html lang="en" xmlns:th="https://www.thymeleaf.org">
<head>
    <title>Photo</title>
</head>
<body>
<h1 th:text="${photo.title}"></h1>
<a th:href="${photo.url}"><img th:src="${photo.thumbnailUrl}" th:alt="${photo.title}"/></a>
</body>
</html>

5.6. Tests

Write a test that verifies the application renders the HTML page.

tests/example/micronaut/test_photos_controller.py
from typing import Annotated

import pytest
import requests
import java
from micronaut.context.annotation import Requires
from micronaut.http.annotation import Controller, Get, PathVariable
from pyronaut.test import MicronautTest, micronaut_test_fixture

from example.micronaut.photo import Photo

EmbeddedServer = java.type("io.micronaut.runtime.server.EmbeddedServer")


@Requires(property="spec.name", value="PhotosControllerTest")
@Controller
class PhotosApi:

    @Get("/photos/{id}")
    def find_by_id(self, id: Annotated[int, PathVariable]) -> Photo:
        return Photo(
            albumId=1,
            title="accusamus beatae ad facilis cum similique qui sunt",
            url="https://via.placeholder.com/600/92c952",
            thumbnailUrl="https://via.placeholder.com/150/92c952",
        )


@pytest.fixture
def photos_context(request):
    fixture = micronaut_test_fixture(
        request,
        MicronautTest(
            environments=["test"],
            transactional=False,
            properties={
                "spec.name": "PhotosControllerTest",
            },
        ),
    )
    yield fixture
    fixture.stop()


@pytest.fixture
def my_context(request, photos_context):
    photos_server = photos_context[EmbeddedServer]
    fixture = micronaut_test_fixture(
        request,
        MicronautTest(
            environments=["test"],
            transactional=False,
            properties={
                "micronaut.http.services.photos.url": f"http://localhost:{photos_server.getPort()}",
            },
        ),
    )  (1)
    yield fixture
    fixture.stop()


@pytest.fixture
def client(my_context):
    return requests.with_context(my_context)  (2)


def test_photo(client):
    response = client.get("/photos/1", headers={"Accept": "text/html"})

    assert response.status_code == 200
    html = response.text
    assert "<!DOCTYPE html>" in html
    expected_url = "https://via.placeholder.com/600/92c952"
    expected_title = "accusamus beatae ad facilis cum similique qui sunt"
    expected_thumbnail_url = "https://via.placeholder.com/150/92c952"
    assert f"<h1>{expected_title}</h1>" in html
    assert (
        f'<a href="{expected_url}"><img src="{expected_thumbnail_url}" alt="{expected_title}"/></a>'
        in html
    )
1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info.
2 Inject the HttpClient bean and point it to the embedded server.

6. Testing the Application

To run the tests:

pyronaut install
pyronaut validate-config
pyronaut test

7. Next Steps

Explore more features with Micronaut Guides.

Read more about Micronaut Views.

8. License

All guides are released with an Apache License 2.0 for the code and a Creative Commons Attribution 4.0 license for the writing and media (images).