-
Notifications
You must be signed in to change notification settings - Fork 73
v1 Tutorial ‐ a full stack feature
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.
- 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.
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:
- the application itself (with the Express backend serving the React client - see v1 Architecture#production-mode); and
- a Postgres database (free tier);
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
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.
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.
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"}
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.
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.
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:
- Start the visit with a POST request to the API endpoint (note this is relative,
"/api/visits"
) fromclient/src/App.js
(as we only want it to happen once when the app is loaded); and - Consume the
visit
prop inclient/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