Skip to content

v1 Tutorial ‐ a full stack feature

Jonathan Sharpe edited this page Aug 24, 2024 · 1 revision

This walks you through implementing a full-stack feature in the CYF Postgres version of the starter kit. You can see the result on the tutorial branch.

Initial setup

  • Follow the instructions in the ./README.md to create a team repository and deploy it to Render.
  • Once you've deployed successfully, update the About settings on the repo homepage to include the deployed app URL (by default it will be something like https://starter-kit-[stuff].onrender.com) as the "Website".
  • Use a pinned message or bookmarks in your team Slack channel to share the links to the repository and the deployed app.
  • Follow the instructions in v1 Dev setup#installation to create a local development environment.

Deployment details

The default deployment described in the ./README.md is to Render, using a "blueprint" (see ./render.yaml). This implements the "Infrastructure as Code" pattern, it describes the two "resources" required to run the system:

and configures them to talk to each other. The overall system therefore looks like this:

flowchart LR
   U([User])
   C(Client - React)
   S(Server - Express)
   D[(Postgres)]

   subgraph Browser
       C
   end

   subgraph Render
       subgraph Blueprint
           S
           D
       end
   end

   U -.- C
   C --REST API--- S
   S -.- D
Loading

Connecting to GitHub allows Render to watch for updates to the specified branch (usually the default branch, main) and redeploy the app as it changes. This can include changes to the render.yaml, if you need any additional resources (e.g. Redis, for queues or caching) to be deployed for your application. You can set all of the same resources and configuration up manually, referring to the settings in the blueprint file, but without the connection to GitHub you'll need to manually trigger deploys as required.

Implementing a feature

Let's start with a very simple user story:

As a visitor to the site

I want to see when the site was initially loaded

So that I know how long I've been browsing it for

We want to do something full-stack so let's assume for each visit we'll store some information in the database, exposing the time of the visit's start to the frontend.

API design

Given that this is not a safe operation, we shouldn't make it a GET request. Semantically what we're doing is creating a visit, so POST /api/visits would make sense, something like:

$ curl -X POST http://localhost:3000/api/visits
{"id":1,"visited_at":"2023-07-08T09:18:51.976Z"}

Database

We'll need a table to store the visits in, and it's good practice to use migrations to manage the database. Install node-pg-migrate (note that this dependency will be required by the Express app at runtime, so is a non-development dependency) and update the scripts to ensure we run the migrations before trying to start the server in production mode*:

$ npm install node-pg-migrate
# ...
 		"lint": "npm run lint:eslint && npm run lint:prettier -- --check",
+		"migration": "node-pg-migrate",
 		"preserve": "npm run build",
 		"serve": "npm start",
+		"prestart": "npm run migration -- up",
 		"start": "node dist/server.js",

Create the first migration using the CLI and set up the "up" (create a new table) and "down" (delete the created table) migrations in the file:

$ npm run migration -- create create-visits

> [email protected] migration
> node-pg-migrate create create-visits

Created migration -- path/to/migrations/1688806962417_create-visits.js
// migrations/1688806962417_create-visits.js

exports.up = (pgm) => {
  pgm.createTable("visits", { id: "id", visited_at: "datetime" });
};

exports.down = (pgm) => {
  pgm.dropTable("visits");
};

Run all migrations to bring your local database "up" to the latest definition (this will automatically use the DATABASE_URL from your .env file):

$ npm run migration -- up

> [email protected] migration
> node-pg-migrate up

> Migrating files:
> - 1688806962417_create-visits
### MIGRATION 1688806962417_create-visits (UP) ###
CREATE TABLE "visits" (
  "id" serial PRIMARY KEY,
  "visited_at" timestamp
);
INSERT INTO "public"."pgmigrations" (name, run_on) VALUES ('1688806962417_create-visits', NOW());

This creates the table defined in the migration and also tracks which migrations have been run in a pgmigrations table, to ensure each migration only gets run once.

API implementation

We could add everything in server/api.js, but it's better from an architectural perspective to have a "feature directory" for all of the logic related to visits, exposing a router we can mount at the appropriate API route:

diff --git a/server/api.js b/server/api.js
index 1826542..160ba53 100644
--- a/server/api.js
+++ b/server/api.js
@@ -1,6 +1,7 @@
 import { Router } from "express";
 
 import logger from "./utils/logger";
+import visitRouter from "./visits";
 
 const router = Router();
 
@@ -9,4 +10,6 @@ router.get("/", (_, res) => {
 	res.json({ message: "Hello, world!" });
 });
 
+router.use("/visits", visitRouter);
+
 export default router;

Within the visits directory we'll have three files, each with a particular responsibility:

/server
    /visits
        index.js  # transport - deal with the requests and responses
        repository.js  # persistence - deal with the DB queries
        service.js  # business - our domain-specific logic

This might seem a bit excessive for one single simple endpoint, but as you add more endpoints and functionality it helps to keep the app scalable and understandable:

// server/visits/index.js

import { Router } from "express";

import { trackVisit } from "./service";

const router = Router();

router.post("/", async (_, res, next) => {
  try {
    const visit = await trackVisit();
    res.status(201).json(visit);
  } catch (err) {
    next(err);
  }
});

export default router;
// server/visits/service.js

import * as visits from "./repository";

export function trackVisit() {
  return visits.create(new Date());
}
// server/visits/repository.js

import db from "../db";

export async function create(visited_at) {
  const { rows: [visit] } = await db.query(
    "INSERT INTO visits (visited_at) VALUES ($1) RETURNING *",
    [visited_at],
  );
  return visit;
}

Note that: Express is only used in the transport layer; Postgres is only used in the persistence layer; and the business layer is pure JavaScript, free of the details of either HTTP requests/responses or database queries.

Test out the API with e.g. cURL or Postman - if it's working, this would be a good time for a commit and pull request (as the new API endpoint can be deployed independently of the UI consuming it). The migration will be run in Render and create the table in the Postgres database there, so you shouldn't ever need to access that DB directly.

UI implementation

We want to show the visit start to the user in a friendly way, which suggests some kind of date formatting library. There are various options since Moment.js got deprecated, but here I'll use date-fns. Note that this will only be used in the client React application, so it's a development dependency (but if we started using it in the backend too, we'd have to move it to non-dev dependencies).

$ npm install --save-dev date-fns
# ...

Implementation then has two steps:

  1. Start the visit with a POST request to the API endpoint (note this is relative, "/api/visits") from client/src/App.js (as we only want it to happen once when the app is loaded); and
  2. Consume the visit prop in client/src/pages/Home.js to show the user when their visit started.

As we did in the server we'll split this into layers based on separate concerns - in this case introducting a visit service to manage the API calls, so the presentation components don't need to be aware of the details:

// client/src/services/visitService.js

export async function startVisit() {
  const res = await fetch("/api/visits", { method: "POST" });
  if (!res.ok) {
    throw new Error(res.statusText);
  }
  const { visited_at } = await res.json();
  return new Date(visited_at);
}
diff --git a/client/src/App.js b/client/src/App.js
index 20b5fec..e981e2b 100644
--- a/client/src/App.js
+++ b/client/src/App.js
@@ -1,13 +1,23 @@
+import { useEffect, useState } from "react";
 import { Route, Routes } from "react-router-dom";
 
 import About from "./pages/About";
 import Home from "./pages/Home";
+import { startVisit } from "./services/visitService";
 
-const App = () => (
-	<Routes>
-		<Route path="/" element={<Home />} />
-		<Route path="/about/this/site" element={<About />} />
-	</Routes>
-);
+const App = () => {
+	const [visit, setVisit] = useState();
+
+	useEffect(() => {
+		startVisit().then(setVisit);
+	}, []);
+
+	return (
+		<Routes>
+			<Route path="/" element={<Home visit={visit} />} />
+			<Route path="/about/this/site" element={<About />} />
+		</Routes>
+	);
+};
 
 export default App;
diff --git a/client/src/pages/Home.js b/client/src/pages/Home.js
index 5895fbc..67bb091 100644
--- a/client/src/pages/Home.js
+++ b/client/src/pages/Home.js
@@ -1,10 +1,11 @@
+import { formatRelative } from "date-fns";
 import { useEffect, useState } from "react";
 import { Link } from "react-router-dom";
 
 import "./Home.css";
 import logo from "./logo.svg";
 
-export function Home() {
+export function Home({ visit }) {
 	const [message, setMessage] = useState("Loading...");
 
 	useEffect(() => {
@@ -37,6 +38,7 @@ export function Home() {
 				</h1>
 				<Link to="/about/this/site">About</Link>
 			</div>
+			{visit && <p>Visit started: {formatRelative(visit, new Date())}.</p>}
 		</main>
 	);
 }

You should now see something like "Visit started: today at 11:26 AM." on the homepage. If you click the link to visit the About page, wait a minute or two then click the Back arrow in your browser it should still stay on the original time, whereas if you refresh the page that will start a new visit with a new start time. You should also be able to see these visit in the visits table in your local database.


* If you intend to use the Docker build, you'll need to update the Dockerfile to include the new migrations/ directory too:

diff --git a/Dockerfile b/Dockerfile
index c34c83a..45dc2f6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -32,6 +32,7 @@ COPY --from=build /home/node/package*.json ./
 RUN npm ci --omit dev
 
 COPY --from=build /home/node/dist ./dist
+COPY ./migrations ./migrations
 
 ENV PORT=80
 EXPOSE 80
Clone this wiki locally