CTEC2712 Web application development

Middleware

Building an application router with middleware

Dr Graeme Stuart

Contents

  1. Application architecture
  2. Array.prototype.find
  3. A simple application router
  4. Use it like this
  5. Application architecture
  6. middleware
  7. An example middleware function
  8. Upgrading the context
  9. Implementing global middleware
  10. Using global middleware
  11. Route-based middleware
  12. Validation middleware
  13. Using route-based middleware
  14. Controllers can also be middleware
  15. Routing multiple controllers

Application architecture

Our MVC application is nearly completes

Application architecture showing router, middleware and MVC components

We need to implement the router and middleware.

1

Array.prototype.find

Arrays have a find method which returns the first element that returns true from the given callback function.

1
2
3
const items = ['apples', 'bananas', 'cherries'];
items.find(item => item.length > 6); // 'bananas'
items.find(item => item[1] == "h"); // 'cherries'

Our routing logic can use this same pattern to find a matching route.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const routes = [
    {path: "/", method: "GET", handler: homeController}, 
    {path: "/items", method: "GET", handler: itemsController}, 
    {path: "/items", method: "POST", handler: addItemController}, 
];

function server(request) {
    const path = new URL(request.url).pathname;
    const route = routes.find(route => {
        route.path == path && route.method = request.method
    });
    return route.handler(request) // passing request to correct controller
}

To implement this, we need to maintain an array of routes..

2

A simple application router

The router class handles an array of routes. Each route matches a pattern and a method to a handler function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
export default class ApplicationRouter { 
    constructor() { 
        this.routes = [];
    }

    register(method, pattern, handler) { 
        if (typeof pattern == "string") pattern = new URLPattern({ pathname: pattern });
        this.routes.push({ method, pattern, handler });
    }

    get(...args) { this.register("GET", ...args); }
    post(...args) { this.register("POST", ...args); }

    handle(ctx) { 
        const { request } = ctx;
        const finder = ({ method, pattern }) => request.method == method && pattern.test(request.url);
        const {pattern, handler} = this.routes.find(finder);
        ctx.params = pattern.exec(request.url).pathname.groups;
        return handler(ctx);
    }
}

The handle function hands over to the first route that matches the request.

3

Use it like this

We create an instance of our application and register some routes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import ApplicationRouter from "./tools/router.js";

const app = new ApplicationRouter();

app.get("*.(css|js)", staticController);
app.get("/register", registrationFormController);
app.post("/users", createUserController);
app.get("/login", loginFormController);
app.post("/sessions", loginController);
app.post("/logout", logoutController);

app.get("/", homeController);
app.get("/items", itemsController);
app.post("/items", createItemController);
app.post("/items/:itemId/delete", deleteItemController);

app.get("*", notFoundController);
app.post("*", notFoundController);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

export function server(request) {
    const path = new URL(request.url).pathname;
    const method = request.method;
    console.log("\n", method, path);

    const session = currentSession(request);
    const ctx = { request, session };

    return app.handle({request, session});
}

The router handles all incoming requests.

4

Application architecture

Our MVC application is nearly completes

Application architecture showing router, middleware and MVC components

We need to implement the router and middleware.

5

middleware

Middleware functions wrap around the request handler.

Middleware are shown as concentric rings around the handler

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// middleware functions look like this
// The next function will execute the rest of the chain to generate a response
// We always return a response
export async function myMiddleware1(ctx, next) {
    // do things here
    const response = await next(ctx);
    // do things here
    return response;
}

// this one just logs
export function myMiddleware2(ctx, next) {
    console.log("middleware!!");    
    return next(ctx);
}

// This one upgrades the ctx object
export function myMiddleware3(ctx, next) {
    ctx.message = "middleware!!!";
    return next(ctx);
}

Middleware can execute code before and after the request handler completes.

6

An example middleware function

Middleware functions receive a context object and a next function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
export async function withLogs(ctx, next) {
    const { request } = ctx;

    // extract the information we need from the request
    const path = new URL(request.url).pathname;
    const method = request.method;    

    // log the request information
    console.log("\n", method, path);

    // complete the request handling to generate a response
    const response = await next(ctx);

    // log the response information
    console.log("status:", response.status);

    // return the response
    return response;    
}
GET /items
logged in as: myUsername
Access granted to protected route
status: 200

GET /styles.css
logged in as: myUsername
[2026-03-15 16:25:50] [GET] /styles.css 304
status: 304

GET /favicon.ico
logged in as: myUsername
Not found!
status: 404

They always return the result of passing the context into the next function.

7

Upgrading the context

Middleware functions can add information to the context. Here we add the session to the context.

1
2
3
4
5
6
export function withSession(ctx, next) { 
    const { request } = ctx;
    ctx.session = currentSession(request);
    console.log(ctx.session ? `logged in as: ${ctx.session.username}` : "Not logged in");
    return next(ctx);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export function requiresSession(ctx, next) { 
    const { session } = ctx;
    if (!session) {
        console.log("Access denied to protected route");
        const errors = {
            credentials: "Login to access this page"
        }        
        return render(loginFormView, {errors}, ctx, 401);
    }
    console.log("Access granted to protected route");        
    return next(ctx);
}
1
2
3
4
5
6
7
8
9
export function excludesSession(ctx, next) {
    const { session, headers } = ctx;
    if (session) {
        console.log("Access denied to protected route");        
        return redirect("/", "log out first", headers);
    }
    console.log("Access granted to protected route");
    return next(ctx);
}

They can also shortcut the request and return a response early.

8

Implementing global middleware

Middleware implementation relies on a recursive pattern

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export default class ApplicationRouter { 
    constructor() { 
        this.routes = [];
        this.middleware = [];
    }

    //...

    use(middlewareFn) {
        this.middleware.push(middlewareFn);
    }

    chain(ctx, middleware, handler) {
        if (middleware.length == 0) return handler(ctx);
        const [nextMiddleware, ...remainingMiddleware] = middleware;
        const next = (ctx) => this.chain(ctx, remainingMiddleware, handler);
        return nextMiddleware(ctx, next);
    }

    handle(ctx) { 
        const { request } = ctx;
        const finder = ({ method, pattern }) => request.method == method && pattern.test(request.url);
        const { pattern, handler } = this.routes.find(finder);
        ctx.params = pattern.exec(request.url).pathname.groups;
        return this.chain(ctx, this.middleware, handler);
    }
}

calling the use method adds middleware to the chain.

9

Using global middleware

Using global middleware is simple. We just pass our functions into the use method.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const app = new ApplicationRouter();

app.use(withLogs);
app.use(withSession);

app.get("*.(css|js)", staticController);

//...
// more routes here
//...

app.get("*", notFoundController);
app.post("*", notFoundController);

export function server(request) {
    return app.handle({ request });
}

However, we can’t require a session on every request.

10

Route-based middleware

To apply middleware selectively we need to pass functions when we register routes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
export default class ApplicationRouter { 

    //...

    register(method, pattern, handler, ...middleware) { 
        if (typeof pattern == "string") pattern = new URLPattern({ pathname: pattern });
        this.routes.push({ method, pattern, handler, middleware });
    }

    //...

    handle(ctx) { 
        const { request } = ctx;
        const finder = ({ method, pattern }) => request.method == method && pattern.test(request.url);
        const { pattern, handler, middleware } = this.routes.find(finder);
        ctx.params = pattern.exec(request.url).pathname.groups;
        const chain = [...this.middleware, ...middleware]
        return this.chain(ctx, chain, handler);
    }
}

Combining the global middleware with the route-based middleware gives a flexible system.

11

Validation middleware

We can use middleware to extract and validate formData.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { validateSchema } from "../tools/validation.js";

export function validate(schema) {
    return async (ctx, next) => {
        const { request } = ctx;
        const formData = await request.formData();
        const validation = validateSchema(formData, schema);
        if (validation.isValid) {
            console.log("Validation: OK");
        } else {
            console.log("Validation: Errors");
            console.log(validation.errors);
            ctx.status = 400;
        }
        return next({ ...ctx, ...validation });
    };
}

The validation data are added to the context, along with the status code.

12

Using route-based middleware

Middleware can now be passed to specific routes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const app = new ApplicationRouter();

app.use(withLogs);
app.use(withSession);

app.get("*.(css|js)", staticController);
app.get("/register", registrationFormController, excludesSession);
app.post("/users", createUserController, validate(userSchema));
app.get("/login", loginFormController, excludesSession);
app.post("/sessions", loginController, excludesSession, validate(userSchema));
app.post("/logout", logoutController, requiresSession);

app.get("/", homeController);
app.get("/items", itemsController, requiresSession);
app.post("/items", createItemController, requiresSession, validate(newItemSchema));
app.post("/items/:itemId/delete", deleteItemController, requiresSession);

app.get("*", notFoundController);
app.post("*", notFoundController);

Each route is defined as a chain of actions.

13

Controllers can also be middleware

Our post handlers can be simplified by adding them to the middleware chain.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export function itemsController(ctx) {
    const { session, errors } = ctx;
    const items = getItems(session.username);
    return render(itemsView, { items, errors, session }, ctx);
}

export function createItemController(ctx, next) {
    const { session, headers, isValid, validated } = ctx;
    if (!isValid) return next(ctx);
    createItem({...validated, username: session.username});
    return redirect("/items", `item '${validated.label}' created`, headers);
}

The createItemController checks validation data and if validation fails, it simple hands over to the next middleware to render the errors in the form. If validation passes, it performs the action and returns a redirect.

14

Routing multiple controllers

We can add controllers as middleware.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const app = new ApplicationRouter();

app.use(withLogs);
app.use(withSession);

app.get("*.(css|js)", staticController);
app.get("/register", registrationFormController, excludesSession);
app.post("/users", registrationFormController, validate(userSchema), createUserController);
app.get("/login", loginFormController, excludesSession);
app.post("/sessions", loginFormController, excludesSession, validate(userSchema), loginController);
app.post("/logout", logoutController, requiresSession);

app.get("/", homeController);
app.get("/items", itemsController, requiresSession);
app.post("/items", itemsController, requiresSession, validate(newItemSchema), createItemController);
app.post("/items/:itemId/delete", deleteItemController, requiresSession);

app.get("*", notFoundController);
app.post("*", notFoundController);

Now our application configuration determines the overall behaviour of our application.

15

Middleware

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

Thanks for listening
Dr Graeme Stuart