DEV Community

Cover image for CryptoFlow: Building a secure and scalable system with Axum and SvelteKit - Part 5
John Owolabi Idogun
John Owolabi Idogun

Posted on • Edited on

CryptoFlow: Building a secure and scalable system with Axum and SvelteKit - Part 5

Introduction

From part 0 to part 4, we built out CryptoFlow's backend service. Though we can quickly use Postman, VS Code's ThunderClient or automated tests to see the endpoints working easily, this isn't all we want. We want to actively interact with the backend service via some intuitive user interface. Also, a layman wouldn't be able to "consume" the service we've built in the last parts. This article introduces building out the user interface of the system. We will be using SvelteKit, a framework that streamlines web development, and TailwindCSS, the utility-first CSS framework. Let's dig in!

Source code

The source code for this series is hosted on GitHub via:

GitHub logo Sirneij / cryptoflow

A Q&A web application to demostrate how to build a secured and scalable client-server application with axum and sveltekit

CryptoFlow

CryptoFlow is a full-stack web application built with Axum and SvelteKit. It's a Q&A system tailored towards the world of cryptocurrency!

I also have the application live. You can interact with it here. Please note that the backend was deployed on Render which:

Spins down a Free web service that goes 15 minutes without receiving inbound traffic. Render spins the service back up whenever it next receives a request to process. Spinning up a service takes up to a minute, which causes a noticeable delay for incoming requests until the service is back up and running. For example, a browser page load will hang temporarily.

Its building process is explained in this series of articles.






Implementation

Step 1: Set up the frontend app

To ease the entire process, kindly head on to creating a project. I named this frontend. After that, proceed to install tailwind CSS with sveltekit. Kindly do this before moving to the next step.

Since we want the frontend application to be served on port 3000, head on to frontend/package.json and update the dev script to:

...
"dev": "vite dev --port 3000",
...
Enter fullscreen mode Exit fullscreen mode

We also need to get the backend URL for server communication. Depending on your backend domain or where you host the backend code, this can change. We will use a .env file at the root of the frontend app:

VITE_BASE_API_URI_DEV=http://127.0.0.1:8008/api
VITE_BASE_API_URI_PROD=
Enter fullscreen mode Exit fullscreen mode

Normally, variables in .env files served by vite should have the VITE_ prefix. You can change the value to the URL of your local web server (axum backend built in the last few articles). The .env file has two variables that store the backend APIs depending on whether it's in a development or production environment.

Retrieving and using this environment variable can be made seamless by exporting it to a .js file. In SvelteKit, I like to do this in frontend/src/lib/utils/constants.js:

export const BASE_API_URI = import.meta.env.DEV
  ? import.meta.env.VITE_BASE_API_URI_DEV
  : import.meta.env.VITE_BASE_API_URI_PROD;
Enter fullscreen mode Exit fullscreen mode

That automatically updates BASE_API_URI from the .env file.

Step 2: Getting the app layout

As earlier stated, we'll use a lot of TailwindCSS to style the web interface. I have already done that and won't be going into the nitty-gritty of the interface or how to use TailwindCSS. That said, let's build out how the general outlook of the app will be. Open up frontend/src/routes/+layout.svelte:

<script>
  import "../app.css";
  import Header from "$lib/components/header/Header.svelte";
  import Footer from "$lib/components/footer/Footer.svelte";
  import Transition from "$lib/components/Transition.svelte";

  export let data;
</script>

<Transition key="{data.url}" duration="{600}">
  <header class="sticky top-0 z-50 bg-[#0a0a0a]">
    <header />
  </header>

  <slot />

  <footer />
</Transition>
Enter fullscreen mode Exit fullscreen mode

The first import, import '../app.css', was gotten from setting up tailwind CSS with sveltekit. Others were components written. I won't show codes for all the components, however, the Transition component looks like this:

<script>
    import { slide } from 'svelte/transition';
    /** @type {string} */
    export let key;

    /** @type {number} */
    export let duration = 300;
</script>

{#key key}
    <div in:slide={{ duration, delay: duration }} out:slide={{ duration }}>
        <slot />
    </div>
{/key}
Enter fullscreen mode Exit fullscreen mode

The component simply allows smooth page transactions. Just to have nice effects while navigating pages. The transition requires a key which should be distinct for each page. A simple and intuitive thing that comes to mind is the page's URL which is generally unique. To make available the page's URL, we need to expose it in +layout.js:

// frontend/src/routes/+layout.js
/** @type {import('./$types').LayoutLoad} */
export async function load({ fetch, url, data }) {
  const { user } = data;
  return { fetch, url: url.pathname, user };
}
Enter fullscreen mode Exit fullscreen mode

We didn't only expose the URL, fetch and user were also exposed. fetch will be used to make some requests to the server later on. I always prefer using the fetch API provided by SvelteKit which extends the normal version of the API. user makes available the data of the currently logged-in user. How do we get it? We'll use the power of the handle method of SvelteKit's server-side hook:

// frontend/src/hooks.server.js
import { BASE_API_URI } from "$lib/utils/constants";

/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
  if (event.locals.user) {
    // if there is already a user  in session load page as normal
    return await resolve(event);
  }
  // get cookies from browser
  const session = event.cookies.get("cryptoflow-sessionid");

  if (!session) {
    // if there is no session load page as normal
    return await resolve(event);
  }

  // find the user based on the session
  const res = await event.fetch(`${BASE_API_URI}/users/current`, {
    credentials: "include",
    headers: {
      Cookie: `sessionid=${session}`,
    },
  });

  if (!res.ok) {
    // if there is no session load page as normal
    return await resolve(event);
  }

  // if `user` exists set `events.local`
  const response = await res.json();

  event.locals.user = response;

  // load page as normal
  return await resolve(event);
}
Enter fullscreen mode Exit fullscreen mode

The server-side handle hook "runs every time the SvelteKit server receives a request and determines the response". Normally, the function takes the event and resolve arguments which represent the incoming request and renders the routes alongside its response respectively. The event object has locals as one of its properties. We can use the Locals TypeScript's interface to hold some data. The data it holds can be accessed and subsequently exposed in the load functions of +page/layout.server.js/ts. The comments in frontend/src/hooks.server.js do enough justice to what it does. To satisfy JsDoc or TypeScript requirements, we need to add the user property to the Locals interface:

// frontend/src/app.d.ts

interface User {
    email: string;
    first_name: string;
    last_name: string;
    id: string;
    is_staff: boolean;
    is_active: boolean;
    thumbnail: string;
    is_superuser: boolean;
    date_joined: string;
}
...
declare global {
    namespace App {
        ...
        interface Locals {
            user: User;
        }
        ...
    }
}
...
Enter fullscreen mode Exit fullscreen mode

Making the user available to all routes is our aim. A good place to ensure this is the +layout.server.js file which can help propagate the user's data.

// frontend/src/routes/+layout.server.js
/** @type {import('./$types').LayoutServerLoad} */
export async function load({ locals }) {
  return {
    user: locals.user,
  };
}
Enter fullscreen mode Exit fullscreen mode

The exposed user object was what we retrieved from the data argument in frontend/src/routes/+layout.js. With that, we can now access the user's data on any page via the data property of the page store.

The other components imported in frontend/src/routes/+layout.svelte are just some simple Tailwind CSS-styled HTML documents.

Step 3: Utility components and functions

In the spirit of keeping most of the tiny details out of the way, we will go through some simple components and functions that will be used in subsequent articles. The first we will see is frontend/src/lib/utils/helpers.js:

// frontend/src/lib/utils/helpers.js
// @ts-nocheck
import { quintOut } from "svelte/easing";
import { crossfade } from "svelte/transition";

export const [send, receive] = crossfade({
  duration: (d) => Math.sqrt(d * 200),

  // eslint-disable-next-line no-unused-vars
  fallback(node, params) {
    const style = getComputedStyle(node);
    const transform = style.transform === "none" ? "" : style.transform;

    return {
      duration: 600,
      easing: quintOut,
      css: (t) => `
                transform: ${transform} scale(${t});
                opacity: ${t}
            `,
    };
  },
});

/**
 * Validates an email field
 * @file lib/utils/helpers/input.validation.ts
 * @param {string} email - The email to validate
 */
export const isValidEmail = (email) => {
  const EMAIL_REGEX =
    /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
  return EMAIL_REGEX.test(email.trim());
};
/**
 * Validates a strong password field
 * @file lib/utils/helpers/input.validation.ts
 * @param {string} password - The password to validate
 */
export const isValidPasswordStrong = (password) => {
  const strongRegex = new RegExp(
    "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})"
  );

  return strongRegex.test(password.trim());
};
/**
 * Validates a medium password field
 * @file lib/utils/helpers/input.validation.ts
 * @param {string} password - The password to validate
 */
export const isValidPasswordMedium = (password) => {
  const mediumRegex = new RegExp(
    "^(((?=.*[a-z])(?=.*[A-Z]))|((?=.*[a-z])(?=.*[0-9]))|((?=.*[A-Z])(?=.*[0-9])))(?=.{6,})"
  );

  return mediumRegex.test(password.trim());
};

/**
 * Test whether or not an object is empty.
 * @param {Record<string, string>} obj - The object to test
 * @returns `true` or `false`
 */

export function isEmpty(obj) {
  for (const _i in obj) {
    return false;
  }
  return true;
}

/**
 * Handle all GET requests.
 * @file lib/utils/helpers.js
 * @param {typeof fetch} sveltekitFetch - Fetch object from sveltekit
 * @param {string} targetUrl - The URL whose resource will be fetched.
 * @param {RequestCredentials} [credentials='omit'] - Request credential. Defaults to 'omit'.
 * @param {'GET' | 'POST'} [requestMethod='GET'] - Request method. Defaults to 'GET'.
 * * @param {RequestMode | undefined} [mode='cors'] - Request mode. Defaults to 'GET'.
 */
export const getRequests = async (
  sveltekitFetch,
  targetUrl,
  credentials = "omit",
  requestMethod = "GET",
  mode = "cors"
) => {
  const headers = { "Content-Type": "application/json" };

  const requestInitOptions = {
    method: requestMethod,
    mode: mode,
    credentials: credentials,
    headers: headers,
  };

  const res = await sveltekitFetch(targetUrl, requestInitOptions);

  return res.ok && (await res.json());
};

/**
 * Get coin prices.
 * @file lib/utils/helpers.js
 * @param {typeof fetch} sveltekitFetch - Fetch object from sveltekit
 * @param {string} tags - The tags of the coins to fetch prices for.
 * @param {string} currency - The currency to fetch prices in.
 */
export const getCoinsPricesServer = async (sveltekitFetch, tags, currency) => {
  const res = await getRequests(
    sveltekitFetch,
    `/api/crypto/prices?tags=${tags}&currency=${currency}`
  );

  return res;
};

/**
 * Format price to be more readable.
 * @file lib/utils/helpers.js
 * @param {number} price - The price to format.
 */
export function formatPrice(price) {
  return price.toLocaleString(undefined, {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  });
}

const coinSymbols = {
  Bitcoin: "BTC",
  Ethereum: "ETH",
  BNB: "BNB",
  Litecoin: "LTC",
  Dogecoin: "DOGE",
  // Add other coins and their symbols here
};

/**
 * Format the coin name to be more readable.
 * @file lib/utils/helpers.js
 * @param {string} coinName - The coin name to format.
 */
export function formatCoinName(coinName) {
  // Format the name by capitalizing the first letter of each word
  const formattedName = coinName
    .toLowerCase()
    .replace(/(?:^|\s)\S/g, (a) => a.toUpperCase());

  // Return the formatted name with the coin's symbol (if available)
  return `${formattedName} (${coinSymbols[formattedName] || "N/A"})`;
}

export function timeAgo(dateString) {
  const date = new Date(dateString);
  const now = new Date();

  const secondsAgo = Math.round((now - date) / 1000);
  const minutesAgo = Math.round(secondsAgo / 60);
  const hoursAgo = Math.round(minutesAgo / 60);
  const daysAgo = Math.round(hoursAgo / 24);

  const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });

  if (secondsAgo < 60) {
    return rtf.format(-secondsAgo, "second");
  } else if (minutesAgo < 60) {
    return rtf.format(-minutesAgo, "minute");
  } else if (hoursAgo < 24) {
    return rtf.format(-hoursAgo, "hour");
  } else if (daysAgo < 30) {
    return rtf.format(-daysAgo, "day");
  } else {
    // Fallback to a more standard date format
    return date.toLocaleDateString();
  }
}
Enter fullscreen mode Exit fullscreen mode

Just some random functions for fading animations; email, password and object validations; sending GET requests; and price (may be removed) and age formatting.

Aside from those functions, there are some other ones in frontend/src/lib/utils/select.custom.js:

// frontend/src/lib/utils/select.custom.js
/**
 * Tag Selection and Suggestion Management.
 * Handles the logic for filtering tags based on user input, selecting tags, and displaying selected tags and suggestions.
 */

import { selectedTags } from "$lib/stores/tags.stores";
import { get } from "svelte/store";

/** @type {HTMLInputElement} */
let inputFromOutside;

/**
 * Set the input element.
 * @file $lib/utils/select.custom.ts
 * @param {HTMLInputElement} inputElement - The input element
 */
export function setInputElement(inputElement) {
  inputFromOutside = inputElement;
}

// Create a Tag type that has id, name, and symbol properties all of type string in jsdoc
/**
 * @typedef {Object} Tag
 * @property {string} id
 * @property {string} name
 * @property {string} symbol
 */

/**
 * Filter tags based on user input and display suggestions.
 * @file $lib/utils/select.custom.ts
 * @param {HTMLInputElement} tagInput - The input element
 * @param {Array<Tag>} allTags - All the tags
 */
export function filterTags(tagInput, allTags) {
  inputFromOutside = tagInput;
  const input = tagInput.value.toLowerCase();

  if (input.trim() === "") {
    clearSuggestions();
    return;
  }

  let $selectedTags = get(selectedTags);

  const suggestions = allTags.filter(
    (tag) =>
      (tag.id.toLowerCase().includes(input) ||
        tag.name.toLowerCase().includes(input)) &&
      !$selectedTags.includes(tag.id)
  );

  displaySuggestions(suggestions);
}

/**
 * Select a tag and display it.
 * @file $lib/utils/select.custom.ts
 * @param {string} tagId - The tag to select
 */
function selectTag(tagId) {
  if (!get(selectedTags).includes(tagId)) {
    // Add tag to selected tags store
    selectedTags.set([...get(selectedTags), tagId]);
    displaySelectedTags();
    inputFromOutside.value = "";
    updateInputPlaceholder();
    clearSuggestions();
  } else {
    // Optional: Provide feedback to the user that the tag is already selected
    console.log("Tag already selected");
  }
}
/**
 * Clear suggestions.
 * @file $lib/utils/select.custom.ts
 */
function clearSuggestions() {
  const container = document.getElementById("suggestions");
  // @ts-ignore
  container.innerHTML = ""; // Clear suggestions
}

/**
 * Remove a tag from the selected tags.
 * @file $lib/utils/select.custom.ts
 * @param {string} tagId - The ID of the tag to remove
 */
function removeTag(tagId) {
  let $selectedTags = get(selectedTags);
  $selectedTags = $selectedTags.filter((t) => t !== tagId);
  selectedTags.set($selectedTags);
  displaySelectedTags();
  updateInputPlaceholder();
}

/**
 * Update the input placeholder text based on the number of selected tags.
 */
function updateInputPlaceholder() {
  let $selectedTags = get(selectedTags);
  if ($selectedTags.length === 4) {
    inputFromOutside.disabled = true;
    inputFromOutside.placeholder = "Max tags reached";
  } else {
    inputFromOutside.disabled = false;
    inputFromOutside.placeholder = `Add up to ${
      4 - $selectedTags.length
    } more tags`;
  }
}

/**
 * Display suggestions to the user.
 * @file $lib/utils/select.custom.ts
 * @param {Array<Tag>} tags - The tags to display
 */
function displaySuggestions(tags) {
  /** @type {HTMLElement} */
  // @ts-ignore
  const container = document.getElementById("suggestions");
  container.innerHTML = ""; // Clear existing suggestions

  tags.forEach((tag) => {
    const div = document.createElement("div");
    div.textContent = tag.name;
    div.className = "cursor-pointer p-2 hover:bg-[#145369]";
    div.addEventListener("click", () => selectTag(tag.id)); // Attach event listener
    container.appendChild(div);
  });
}

/**
 * Display selected tags to the user.
 * @file $lib/utils/select.custom.ts
 */
export function displaySelectedTags() {
  const container = document.getElementById("selected-tags");
  // @ts-ignore
  container.innerHTML = ""; // Clear existing tags

  let $selectedTags = get(selectedTags);

  $selectedTags.forEach((tag) => {
    const span = document.createElement("span");
    span.className =
      "inline-block bg-[#145369] rounded-full px-3 py-1 text-sm font-semibold text-white mr-2 mb-2";
    span.textContent = tag;

    const removeSpan = document.createElement("span");
    removeSpan.className = "cursor-pointer text-red-500 hover:text-red-600";
    removeSpan.textContent = " x";
    removeSpan.onclick = () => removeTag(tag); // Attach event listener

    span.appendChild(removeSpan);
    // @ts-ignore
    container.appendChild(span);
  });
}
Enter fullscreen mode Exit fullscreen mode

The appended comments say exactly what the file and functions do. There is a custom store introduced there:

// frontend/src/lib/stores/tags.stores.js
import { writable } from "svelte/store";

/** @type {Array<string>} */
let tags = [];

export const selectedTags = writable(tags);
Enter fullscreen mode Exit fullscreen mode

The next things are the simple components. We start with a responsive but simple loader:

<!-- frontend/src/lib/components/Loader.svelte -->
<script>
  /** @type {number | null} */
  export let width;
  /** @type {string | null} */
  export let message;
</script>

<div class="loading">
  <p class="simple-loader" style={width ? `width: ${width}px` : ''} /> {#if
  message}
  <p>{message}</p>
  {/if}
</div>

<style>
  .loading {
    display: flex;
    align-items: center;
    /* justify-content: center; */
  }
  .loading p {
    margin-left: 0.5rem;
  }
  .simple-loader {
    --b: 20px; /* border thickness */
    --n: 15; /* number of dashes*/
    --g: 7deg; /* gap  between dashes*/
    --c: #2596be; /* the color */

    width: 40px; /* size */
    aspect-ratio: 1;
    border-radius: 50%;
    padding: 1px; /* get rid of bad outlines */
    background: conic-gradient(#0000, var(--c)) content-box;
    --_m: /* we use +/-1deg between colors to avoid jagged edges */ repeating-conic-gradient(
        #0000 0deg,
        #000 1deg calc(360deg / var(--n) - var(--g) - 1deg),
        #0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n))
      ), radial-gradient(farthest-side, #0000 calc(98% - var(--b)), #000 calc(100% -
              var(--b)));
    -webkit-mask: var(--_m);
    mask: var(--_m);
    -webkit-mask-composite: destination-in;
    mask-composite: intersect;
    animation: load 1s infinite steps(var(--n));
  }
  @keyframes load {
    to {
      transform: rotate(1turn);
    }
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Then comes the composable and flexible animated modal:

<!-- frontend/src/lib/components/Modal.svelte -->
<script>
    import { quintOut } from 'svelte/easing';

    import { createEventDispatcher } from 'svelte';

    const modal = (/** @type {Element} */ node, { duration = 300 } = {}) => {
        const transform = getComputedStyle(node).transform;

        return {
            duration,
            easing: quintOut,
            css: (/** @type {any} */ t, /** @type {number} */ u) => {
                return `transform:
            ${transform}
            scale(${t})
            translateY(${u * -100}%)
          `;
            }
        };
    };

    const dispatch = createEventDispatcher();
    function closeModal() {
        dispatch('close', {});
    }
</script>

<div class="modal-background">
    <div transition:modal={{ duration: 1000 }} class="modal" role="dialog" aria-modal="true">
        <a title="Close" class="modal-close" on:click={closeModal} role="dialog">
            <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 384 512">
                <path
                    d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"
                />
            </svg>
        </a>
        <div class="container">
            <slot />
        </div>
    </div>
</div>

<style>
    .modal-background {
        width: 100%;
        height: 100%;
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background: rgba(0, 0, 0, 0.9);
        z-index: 9999;
    }

    .modal {
        position: absolute;
        left: 50%;
        top: 50%;
        width: 40%;
        box-shadow: 0 0 10px hsl(0 0% 0% / 10%);
        transform: translate(-50%, -50%);
    }
    @media (max-width: 990px) {
        .modal {
            width: 90%;
        }
    }
    .modal-close {
        border: none;
    }

    .modal-close svg {
        display: block;
        margin-left: auto;
        margin-right: auto;
        fill: rgb(14 165 233 /1);
        transition: all 0.5s;
    }
    .modal-close:hover svg {
        fill: rgb(225 29 72);
        transform: scale(1.5);
    }
    .modal .container {
        max-height: 90vh;
        overflow-y: auto;
    }
    @media (min-width: 680px) {
        .modal .container {
            flex-direction: column;
            left: 0;
            width: 100%;
        }
    }
</style>
Enter fullscreen mode Exit fullscreen mode

Last on the list is the error-showing component:

<!-- frontend/src/lib/components/ShowError.svelte -->
<script>
    import { receive, send } from '$lib/utils/helpers';

    /** @type {any}*/
    export let form;
</script>

{#if form?.errors}
    <!-- Error Message Display -->
    {#each form?.errors as error (error.id)}
        <p
            class="text-red-500 p-3 text-center mb-4 italic"
            in:receive={{ key: error.id }}
            out:send={{ key: error.id }}
        >
            {error.message}
        </p>
    {/each}
{/if}
Enter fullscreen mode Exit fullscreen mode

With that, we come to the end of the first start of building our application's front end!

Outro

Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, health care, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn: LinkedIn and Twitter: Twitter.

If you found this article valuable, consider sharing it with your network to help spread the knowledge!

Top comments (0)