CTEC2712 Web application development

Routing and the MVC pattern

Server architecture

Dr Graeme Stuart

Contents

  1. JavaScript Modules
  2. Request Handlers
  3. routing
  4. Server Architecture
  5. SQLite
  6. Deno tasks
  7. The MVC pattern
  8. Models
  9. Views
  10. Tooling
  11. controllers
  12. URL Pattern API
  13. context object

JavaScript Modules

We can use export and import statements to break our project across multiple files.

We can explicitly export variables, functions (and classes) from a module by adding the export keyword to the declaration.

1
2
3
export function server() {
    return new Response("hello world");
}

Alternatively, we can add an export list to the end of the file like this. As long as the names we include exist, they will be exported.

1
2
3
4
5
function server() {
    return new Response("hello world");
}

export { server };

In another JavaScript module, we can use the import declaration to access exported variables, functions and classes from another module.

1
2
3
import { server } from './path/to/other-module.js';

Deno.serve(server);

In this lecture, we will be carefully considering how to structure our web server.

1

Request Handlers

A request handler is a function that takes a request and returns a response. In these cases, we are exporting them from their own modules.

A simple example ignores the request.

1
2
3
export function helloWorld(request) {
    return new Response("Hello world");
}

More commonly, return an HTML response.

1
2
3
4
export function helloHTML(request) {
    const headers = { "content-type": "text/html" }
    return new Response("<p>Hello world</p>", { headers });
}

A handler might also be dynamic, responding to the request more directly

1
2
3
4
5
export function helloName(request) {
    const headers = { "content-type": "text/html" }
    const myName = new URL(request.url).searchParams.get('name') || "world";
    return new Response(`<p>Hello ${myName}</p>`, { headers });
}

Its common to create a fallback handler for requests which don’t match anything we have

1
2
3
4
5
export function notFound(request) {
    const headers = { "content-type": "text/html" }
    const path = new URL(request.url).pathname;
    return new Response(`<p>${path} not found</p>`, { headers, status: 404 });
}

Request handlers MUST return a response. In deno, if your code crashes, as 500 response is automatically returned.

2

routing

In web development a route is a URL path (e.g. /hello/world) that maps to a specific request handler function on the server.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { helloWorld } from './handlers/helloWorld.js';
import { helloHTML } from './handlers/helloHTML.js';
import { helloName } from './handlers/helloName.js';
import { notFound } from './handlers/notFound.js';

function server(request) {
    const path = new URL(request.url).pathname;
    const method = request.method;
    if(path == "/hello/world" && method == "GET") return helloWorld(request);
    if(path == "/hello/html" && method == "GET") return helloHTML(request);
    if(path == "/hello/name" && method == "GET") return helloName(request);
    return notFound(request);
}

Routing is the mechanism that matches an incoming request (URL and method) to an existing route and passes the request on to the appropriate route handler function.

3

Server Architecture

Structuring our code from the beginning well will allow our project to expand smoothly.

The overall logical structure involves a router and an MVC pattern.

Our files can be organised in different ways, but this is a good example.

A diagram showing web server architecture with a router and MVC structure

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
MyApplication
    ├── models
    │      └── items.js
    ├── views
    │      └── items.js
    ├── controllers
    │      └── items.js
    ├── tools
    │      ├── db.js
    │      ├── redirect.js
    │      └── render.js
    ├── tasks
    │      └── db-init.js
    ├── main.js
    └── server.js

The request must be routed to a controller which will coordinate between models and views.

4

SQLite

SQLite is a C-language library that implements a fast and reliable SQL database engine. SQlite databases can be created in memory or in simple files so no database server is required.

We will use the jsr:@db/sqlite library for integrating SQLite into our projects. Install the library using the following command in a terminal.

1
deno add jsr:@db/sqlite

To connect to a database, we need a db.js file which exports an instance of the database connection object.

1
2
3
import { Database } from '@db/sqlite';

export const db = new Database("application.db");

We can use this Database object to execute SQL in any scripts we need and within our models.

1
2
3
4
5
6
7
import { db } from '../tools/db.js';

db.exec(`
CREATE TABLE items (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    label TEXT NOT NULL
);`);

All our models can use SQL to interact with our SQLite database via db.exec and db.prepare.

5

Deno tasks

Adding a deno.json file to our project allows us to, amongst other things, define tasks that we can execute using the deno task sub-command.

{
    "tasks": {
        "serve": "deno --watch --allow-all main.js",
        "db:init": "deno --allow-all ./tasks/db-init.js"
    }
}
deno task serve

deno task db:init

Setting up shortcuts like this will help us to easily rebuild our database from scratch. We can also implement other helper scripts, for example to import some seed data.

6

The MVC pattern

The model, view, controller pattern is a tried and tested approach to maintain a separation of concerns between different aspects of our application.

Models are responsible for interfacing with the data persistence mechanism.

1
2
3
4
import { db } from './db.js';
export function getItems() {
    return db.prepare("SELECT * FROM items;").all();
}

Views are responsible for generating the user interface. In this case that means functions that return HTML strings.

1
2
3
4
5
6
const itemToHTML = (item) => `<li>${item.label}</li>`;

export function itemList(items) {
    const listItems = items.map(itemToHTML);
    return `<ul>${listItems.join("")}</ul>`;
}

Controllers make decisions to generate a response. They mediate between the model and the view.

1
2
3
4
5
6
7
8
9
import { getItems } from '../models/items.js';
import { itemsList } from '../views/items.js';

export function itemsController(request) {
    const items = getItems();
    const html = itemsList(items);
    const headers = {"content-type": "text/html"};
    return new Response(html, { headers })
}

Controllers are request handlers with a bit more structure. They respond to user interaction (i.e. requests) and decide how to generate a response. They are supported by models and views which handle the details.

7

Models

Models handle data. They provide functions for controllers to use.

Our model files encapsulate knowledge of the SQL table structure. There should be no SQL code in our controllers or views.

Inside the models folder, we will have modules for each aspect of our data. This might mean one file per database table.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import { db } from './db.js';

export function getItems() {
    return db.prepare("SELECT * FROM items;").all();
}

export function createItem(item) {
    return db.prepare(
        "INSERT INTO items (name) VALUES (?);"
    ).run(item);
}
1
2
3
4
5
6
MyApplication
    └── models
          ├── items.js
          ├── lists.js
          ├── sessions.js
          └── users.js

This neutral interface means controllers don’t need to know where or how the data are stored. All they need to know is what functions to call.

8

Views

Views handle HTML. They also provide functions for controllers to use.

Our user interface will be built on the server as HTML strings. Our view files encapsulate knowledge of the user interface structure. There should be no HTML code in our controllers or models.

Inside the views folder, we will have modules for each aspect of our user interface. This might mean one file per URL.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const itemToHTML = (item) => `<li>${item.label}</li>`;

export function itemList(items) {
    const listItems = items.map(itemToHTML);
    return `
    <section aria-labelledby="item-heading">
        <h2 id="item-heading">Items</h2>
        <ul>${listItems.join("")}</ul>
    </section>`;
}
1
2
3
4
5
MyApplication
    └── views
          ├── items.js
          ├── lists.js
          └── auth.js

This neutral interface means controllers don’t need to know how the user interface works. All they need to know is what functions to call.

9

Tooling

Convenience functions can be reused by many controllers. e.g. for generating HTML or redirect responses.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
export function render(viewFn, data={}, status=200) {
    const content = viewFn(data);
    const html = `
    <!doctype html>
    <html>
        <head><title>My Application</title></head>
        <body>
            <header><h1>My Application</h1></header>
            <main>${content}</main>
        </body>
    </html>
    `
    const headers = new Headers();
    headers.set("content-type", "text/html");
    return new Response(html, { headers, status })
}
1
2
3
4
5
6
export function redirect(location) {
    const status = 303;
    const headers = new Headers();
    headers.set("location", location);
    return new Response(null, { headers, status });
}

These can get more complex as necessary as your application grows.

10

controllers

Controllers are request handlers with more structure. They are the primary decision-making code but they don’t care about how data are stored or how the HTML is structured. Models and views should only be invoked from controllers (or possibly from tasks).

1
2
3
4
5
6
import { loginForm } from '../views/auth.js';
import { render } from '../tools/render.js';

export function loginFormController(request) {
    return render(loginForm);
}
1
2
3
4
5
6
7
8
9
import { createItem } from '../models/items.js';
import { redirect } from '../tools/redirect.js';

export async function createItemController(request) {
    const formData = await request.formData();
    const item = formData.get('item');
    createItem(item);
    return redirect("/items");
}

Controllers access data from models and generate responses by either rendering views or redirecting the browser to another route.

11

URL Pattern API

We need to distinguish between collections (e.g. /items) and individual resources (e.g. /items/123) with dynamic values.

The URLPattern API allows us to create patterns that match our application routes.

1
2
const itemsPattern = new URLPattern({ pathname: "/items" });
const itemPattern = new URLPattern({ pathname: "/items/:itemId" });

We can test the patterns against URLs.

1
2
3
4
5
// check to see if our url matches
itemsPattern.test("http://localhost:8000/items");    // true
itemsPattern.test("http://localhost:8000/items/25"); // false
itemPattern.test("http://localhost:8000/items");     // false
itemPattern.test("http://localhost:8000/items/25");  // true

We can extract the (so-called named groups) from URLs.

1
2
3
// extract matching groups
const match = itemPattern.exec("http://localhost:8000/items/25");
const { itemId } = match.pathname.groups; // "25"

This gives us our primary key data and allows us to create a URL for each individual record.

12

context object

The routing context object allows for a more general purpose controller interface.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { itemsController, itemController, createItemController } from './controllers/items.js';
import { notFoundController } from './controllers/notFound.js';

const itemsPattern = new URLPattern({pathname: "/items"});
const itemPattern = new URLPattern({pathname: "/items/:itemId"});

function server(request) {
    const { method, url } = request;
    
    if(itemsPattern.test(url) && method == "GET") return itemsController({ request });
    
    if(itemsPattern.test(url) && method == "POST") return createItemController({ request });

    if(itemPattern.test(url) && method == "GET") {
        const { itemId } = itemPattern.exec(url).pathname.groups;
        return itemController({ request, itemId });
    };
    
    return notFound(request);
}

Rather than taking a request as an argument, controllers (and eventually our router and middleware) can take an object which has the request as a property.

13

Routing and the MVC pattern

If you have any questions, now is a good time to ask.

Thanks for listening
Dr Graeme Stuart