Tech & Engineering Blog

Late Night Confessions — Building a Website Using Rust, Rocket, Diesel, and Askama — Part 3

Written by Johnny Tordgeman | Apr 29, 2021 3:39:00 PM

This is part 3 in a three-part series. Read part 1 here and part 2 here.

With a working API layer under our belt, it's time to put in the final piece of the puzzle: the presentation layer. This layer consists of static resources (HTML, CSS, JavaScript) and API calls to display and add confessions.

✏️ As this tutorial is not frontend focused, I wanted to keep things simple and use vanilla JavaScript, HTML and CSS. I will also not go into too much detail with why or how these are implemented, as we are not really here for do frontend stuff are we? 😎

Working With Templates

When we serve index.html back to the user, we want it to have a confession already so the user won’t load the page and then wait for the first API call to return the confession. That basically means that our application needs to parse the index.html static file, find the right place to inject a confession, and then inject the content in the correct place after fetching a random confession from the database. Now that might work if you have one item to inject, but what if you have two or ten?

That's where templates come into play. Templates allow us to have a static HTML that contains placeholders (aka variables) that the templating engine will replace with the content of our choice. In addition, templates allow us to use tags to control the template logic if statements, for example). However, we will not be dealing with those in this tutorial.

For Late Night Confessions, I chose Askama as the templating engine because of its easy integration and simple syntax. Let’s create a template for our index.html file (trust me, it's easier than you think):

  1. In the project root folder, create a new folder called templates.
  2. Inside the templates folder, create a new file called index.html with the following content:
<!DOCTYPE html>
      <html>
         <head>
    		<meta charset="utf-8" />
            	<title>Late Night Confessions</title>
               	<link href="https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,300;1,400&display=swap" rel="stylesheet" />
               	<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cirrus-ui@0.6.0/dist/cirrus.min.css" />
               	<link rel="stylesheet" href="/static/styles.css" />
               	<script src="./static/confessions.js"></script>
    		</head>
    		<body>
    			<div class="stars"></div>
               	<div class="twinkling"></div>
               	<div class="clouds"></div>
               	<div class="main">
               		<div class="avatar avatar--xlarge">
               			<img src="https://images.unsplash.com/photo-1483706600674-e0c87d3fe85b?dpr=1&auto=format&fit=crop&w=256&h=256&q=60&cs=tinysrgb&crop=fill&bg=fff" />
               		</div>
               		<div class="title">
               			<h1>Late Night Confessions</h1>
               		</div>
               		<div class="confessionView">
               			<div class="card">
               				<div class="card-container">
               					<div class="card-image" id="confessionCardImage"></div>
               				</div>
               				<div class="content">
                              <p></p>
               				</div>
               			</div>
               		</div>
               		<div class="timeBar">
               			<div class="progress">
               				<div class="progress-value"></div>
               			</div>
               		</div>
                 	<div class="addView">
               			<a href="#confession-modal"><button class="outline btn-primary">Add Yours</button></a>
               		</div>
               		<footer>
               			<p><span>♥️</span>  confessions on record!</p>
               		</footer>
               	</div>
    			<div class="modal modal-animated--zoom-in" id="confession-modal">
    				<div class="modal-overlay"></div>
               		<div class="modal-content confessionAdd" role="document">
               			<div class="modal-header">
                        	<a href="#main" class="u-pull-right" aria-label="Close"><span class="xBtn">X</span></a>
               				<div class="modal-title">Confess your <span class="heart">♥️</span> out</p></div>
               			</div>
               			<div class="modal-body">
               				<textarea id="confessionText" placeholder="Enter your confession"></textarea>
               			</div>
               			<div class="modal-footer">
               				<div class="form-section u-text-right">
               					<a href="#main">
               						<button class="btn btn-small u-inline-block">Cancel</button>
               					</a>
               					<button class="btn-primary btn btn-small" id="btnSubmit">Post</button>
               				</div>
               			</div>
               		</div>
               	</div>
    		</div>
     	</body>
    </html>

The vast majority of this file is plain old HTML syntax, take a glance at lines 32 and 47— These are Askama variables:

  • confession (line 32) — This variable will be replaced by an actual confession from the database.
  • total_confessions (line 47) — This variable will be replaced by the total number of confessions in our database.

And that's basically that for making our HTML an Askama template. Let’s quickly add the styles.css and confessions.js files to the site/static folder and get back to Rust to integrate Askama:

styles.css:

    * {
        margin: 0;
        padding: 0;
    }
    body {
        background-color: #000;
    }
    .main {
        z-index: 5;
        position: relative;
        padding-top: 20px;
        max-width: 600px;
        margin: 0 auto;
        height: 100%;
    }
    .title h1 {
        font-size: 36px;
        font-family: 'Lato', sans-serif;
        margin-top: 0.5em;
        margin-left: auto;
        margin-right: auto;
        text-align: center;
        color: #fff;
        background: -webkit-linear-gradient(#eee, #333);
        -webkit-background-clip: text;
        background-clip: text;
        -webkit-text-fill-color: transparent;
        text-shadow: 6px 6px 0px rgba(0, 0, 0, 0.2);
        display: block;
        position: relative;
        z-index: 3;
    }
    @keyframes move-clouds-back {
        from {
            background-position: 0 0;
        }
        to {
            background-position: 10000px 0;
        }
    }
    @keyframes move-twinkle-back {
        from {
            background-position: 0 0;
        }
        to {
            background-position: -10000px 5000px;
        }
    }
    .stars,
    .twinkling,
    .clouds {
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        width: 100%;
        height: 100%;
        display: block;
    }
    .stars {
        background: #000 url(/assets/stars.png) repeat top center;
        z-index: 0;
    }
    .twinkling {
        background: transparent url(/assets/twinkling.png) repeat top center;
        z-index: 1;
        animation: move-twinkle-back 200s linear infinite;
    }
    .clouds {
        background: transparent url(/assets/clouds.png) repeat top center;
        z-index: 2;
        opacity: 0.4;
        animation: move-clouds-back 200s linear infinite;
    }
    .avatar {
        border: 2px solid #333;
    }
    .progress {
        background: rgba(255, 255, 255, 0.1);
        justify-content: flex-start;
        border-radius: 100px;
        align-items: center;
        position: relative;
        padding: 0 5px;
        display: flex;
        height: 20px;
        width: 100%;
        max-width: 600px;
    }
    .progress-value {
        animation: load 30s normal forwards;
        box-shadow: 0 10px 40px -10px #fff;
        border-radius: 100px;
        background: #c21b2b;
        height: 10px;
        width: 0;
    }
    @keyframes load {
        0% {
            width: 100%;
        }
        100% {
            width: 0%;
        }
    }
    .confessionAdd {
        max-width: 450px !important;
        width: 100%;
    }
    .heart {
        color: #c21b2b;
    }
    .addView {
        text-align: center;
        padding-top: 20px;
        padding-bottom: 20px;
    }
    .modal.shown .modal-overlay,
    .modal:target .modal-overlay {
        background-color: rgba(54, 54, 54, 0.8);
    }
    .modal-header span.xBtn {
        font-size: 1.4rem;
        color: #374054;
    }
    .emptyArea {
        border: 2px solid #c21b2b;
    }
    footer {
        width: 100%;
        padding-bottom: 5px;
    }
    footer span {
        color: #f03d4d;
    }

confessions.js:

    const BASE_URL = 'http://localhost:8000';
    
    async function refreshCard(cardContent, cardImage, progressBar) {
        try {
            const resp = await fetch(`${BASE_URL}/api/confession`, {
                headers: {
                    'Content-Type': 'application/json',
                },
            });
            const respJson = await resp.json();
            cardImage.style.backgroundImage = `url('https://source.unsplash.com/featured/600x400/?city,night&seed=${Date.now()}')`;
            cardContent.innerText = respJson.confession;
            progressBar.classList.remove('progress-value');
            void progressBar.offsetWidth;
            progressBar.classList.add('progress-value');
        } catch (e) {
            console.error(e);
        }
    }
    
    window.addEventListener('load', (event) => {
        const progressBar = document.querySelector('.progress-value');
        const cardImage = document.getElementById('confessionCardImage');
        const cardContent = document.querySelector('.content p');
        const btnSubmit = document.getElementById('btnSubmit');
        const footerNote = document.querySelector('footer span').nextSibling;
    
        cardImage.style.backgroundImage = `url('https://source.unsplash.com/featured/600x400/?city,night&seed=${Date.now()}')`;
        btnSubmit.addEventListener('click', async () => {
            const confessionArea = document.getElementById('confessionText');
            confessionArea.classList.remove('emptyArea');
            if (confessionArea.value.length <= 5) {
                confessionArea.classList.add('emptyArea');
            } else {
                btnSubmit.setAttribute('disabled', 'disabled');
                const resp = await fetch(`${BASE_URL}/api/confession`, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({ content: confessionArea.value }),
                });
                if (resp.status === 201) {
                    alert('Your confession is safe with us!');
                    const currentNumber = footerNote.textContent.match(/.+\d/)[0];
                    footerNote.textContent = ` ${parseInt(currentNumber) + 1} confessions on record!`;
                } else {
                    alert('Error saving your confession. Try again later.');
                }
                window.location.href = '/#main';
            }
        });
        progressBar.addEventListener('animationend', async () => {
            await refreshCard(cardContent, cardImage, progressBar);
        });
    });

A few points of interest in our JavaScript:

  • The refreshCard function makes a call to our GET API to get a new random confession and update the UI.
  • The event listener for click validates the text area (so it is not empty, somewhat naive, I know, but 🤷‍♂️), and then if all is well, makes a call to our POST API with the new confession. If everything checks out (line 43), we alert the user everything is fine and update the footer note with the updated saved confessions number.
  • We listen to the animationend event to update the displayed confession once the timer runs off (lines 53–55).

Working With Askama

1. Back in our Rust project, let’s add Askama to the Cargo.toml file:

    [dependencies]
    ...
    askama = "0.10.5"

2. As mentioned earlier, Askama works with templates, so the first thing we need to do is tell Askama where our template is and which variables it's going to have:

    use askama::Template;
    
    #[derive(Template)]
    #[template(path = "index.html")]
    struct HomepageTemplate {
        confession: String,
        total_confessions: i64,
    }

🔬 So what do we have here?

  • Line 3: We derive the Template trait, which is the main trait Askama uses. It includes template related methods such as render.
  • Line 4: We use the template attribute to specify the path to our template. The path is relative to the templates folder in the project root folder. Other than path you can also specify a source (directly set the template, without a template file) or enable debug using the print sub-attribute. Read more about the different uses of the template attribute on the official docs.
  • Lines 5–8: We define the fields the struct will hold, which are identical to the variables we defined in the template itself.

3. We now have two functions that need to get a random confession from the database: get_confession (the handler for the GET API) and root (the handler that renders index.html). To avoid code duplication, let’s extract get_confession’s confession fetching logic to a new function, get_random_confession:

    fn get_random_confession(conn: &PgConnection) -> Result<Confession, diesel::result::Error> {
        schema::confessions::table
            .order(RANDOM)
            .limit(1)
            .first::<Confession>(conn)
    }

Notice that our new function takes a conn variable of type &PgConnection as argument and return a Result of either a Confession (if successful) or diesel’s error type (if there was a database-related error).

4. With the get_random_confession function in place, let’s update the get_confession handler to use the new function as its closure:

    #[get("/confession", format = "json")]
    async fn get_confession(conn: DBPool) -> Result<Json<Confession>, CustomError> {
        let confession: Confession = conn.run(|c| get_random_confession(c)).await?;

        Ok(Json(confession))
    }

5. Lastly, let’s update the root handler to render the template using Askama and set it as the response:

    #[get("/")]
    async fn root(conn: DBPool) -> Result<content::Html<String>, CustomError> {
        let confession_from_db: Confession = conn.run(|c| get_random_confession(c)).await?;
        let confession_count:i64 = conn.run(|c| {
            schema::confessions::table.count().get_result(c)
        }).await?;
    
        let template = HomepageTemplate {
            confession: confession_from_db.confession,
            total_confessions: confession_count,
        };
    
        let response = content::Html(template.to_string());
        Ok(response)
    }

🔬 So what do we have here?

  • Line 2: We changed the return type to Html<String> for success and our CustomError in case of an error.
  • Line 3: We get a random confession from the database using the new get_random_confession function, just like we are doing for the API call to /api/confession.
  • Lines 4–6: We query Postgres for the number of confessions we have saved on it.
  • Lines 8–11: We initiate the HomepageTemplate struct (a.k.a our template definition) with the data from Postgres.
  • Lines 13–14: This is where the magic happens. Askama turns our template into an HTML string (line 13), which we return as the handler response (line 14).

And that's basically it! Go ahead and run cargo run and browse to localhost:8000. You should get something similar to the following:

Summary

Our site creation journey has come to its end, but take a look back at everything you achieved! You now have a web app that is able to handle static files and API requests, uses a Postgres instance for persisting data and takes advantage of templates for quicker and easier HTML manipulation.

I would love to read your comments on this mini-post series over on Twitter, and please join me on my next Rust post series: “Let’s Build a Fitness Tracking Webapp With Rust and Yew”.

Credit Where Credit’s Due