DEV Community

Cover image for AMP CMS: Routes & Pages
Valeria
Valeria

Posted on

AMP CMS: Routes & Pages

The modular system we built so far was providing modules only for API, but we can extend it by rendering AMP pages as well.

Routes

Here are some examples of the routes we can make:

export default {
  "/amp/items/:id": ({ id }) => ({ type: "amp", body: `<h1>${id}</h1>` }), // Renders AMP boilerplate
  "/html/items/:id": ({ id }) => ({
    type: "html",
    data: `<html><head><style>...</style></head><body><div>...</div></body>`,
  }), // Renders  HTML page
  "/json/items/:id": ({ id }) => ({ id }), // Renders JSON
  "/files/:name": ({ name }) => ({
    type: "image/png",
    data: fs.createReadStream(`path/to/files/${name}`),
  }),
  "/json/items/:id": {
    GET: ({ id }) => ({ id }),
    POST: ({ id }) => ({ id }),
  },
};
Enter fullscreen mode Exit fullscreen mode

The last example is looking familiar because apart from the named parameter in the path it's just like APIResolver. Well, it won't be soon, we're proudly promoting APIResolver to ResolverFn and resolvers to routes.

First, let's rename api to core and start adding tests for new functionality to routeRequest.spec.ts:

it("can resolve route with parameters", () => {
    const routes = {
      "/item/:id": ({ id }) => ({ item: id }),
    };
    const { resolver, params } = routeRequest(
      makeUrl("/item/itm_1"),
      "GET",
      routes
    );
    expect(params).to.deep.eq({ id: "itm_1" });
    expect(resolver(params)).to.deep.eq({ item: "itm_1" });
  });
Enter fullscreen mode Exit fullscreen mode

The function now needs to return params alongside the resolver, which should also be reflected in the existing tests.
Another change we made is that the path doesn't always start with /api anymore, so we need to update the tests we had with the new paths:

const routes = {
      "/api/item": {
        POST,
        GET,
        PATCH,
        DELETE,
      },
      "/api/items": {
        GET: getItems,
      },
    };

    expect(routeRequest(makeUrl("/api/item?id=1"), "GET", routes)).to.have.property('resolver',
      GET
    );
Enter fullscreen mode Exit fullscreen mode

We need to update the types, and the routeRequest function itself so that we have valid TypeScript everywhere.
I've changes APIResolvers to Routes:

export type Routes = {
  [path: string]: ResolverFn | Partial<Record<HTTPMethod, ResolverFn>>;
};;
Enter fullscreen mode Exit fullscreen mode

And routeRequest function to:

import { ResolverFn, Routes, HTTPMethod } from "../types";
import { HTTPMethodNotAllowed, HTTPNotFound } from "./errors";

export default function routeRequest(
  url: URL,
  method: HTTPMethod,
  routes: Routes
): { resolver: ResolverFn; params: Record<string, string> } {
  const params = {};
  const path = url.pathname;
  const resolver = routes[path];
  if (!resolver) throw new HTTPNotFound();
  if (typeof resolver === "function") {
    return { params, resolver };
  }
  if (!(method in resolver))
    throw new HTTPMethodNotAllowed(Object.keys(resolver) as any);
  return { params, resolver: resolver[method] };
}
Enter fullscreen mode Exit fullscreen mode

There was more to it, of course, replacing all the import and changing existing modules, but inevitably the tests ran without TypeScript error.

To match parameters with an actual path we turn text into regular expression with capture groups and simply return the first matching route from a set:

import { ResolverFn, Routes, HTTPMethod } from "../types";
import { HTTPMethodNotAllowed, HTTPNotFound } from "./errors";

const cache = new Map<string, RegExp>();

export default function routeRequest(
  url: URL,
  method: HTTPMethod,
  routes: Routes
): { resolver: ResolverFn; params: Record<string, string> } {
  let params;
  let resolver;
  for (let path in routes) {
    if (!cache.has(path)) {
      cache.set(path, buildRegExp(path));
    }
    const re = cache.get(path);
    const matches = url.pathname.match(re);
    if (matches) {
      params = matches.groups ?? {};
      resolver = routes[path];
      break;
    }
  }
  if (!resolver) throw new HTTPNotFound();
  if (typeof resolver === "function") {
    // IDEA: allow only GET/HEAD here
    return { params, resolver };
  }
  if (!(method in resolver))
    throw new HTTPMethodNotAllowed(Object.keys(resolver) as any);
  return { params, resolver: resolver[method] };
}

export function buildRegExp(path: string): RegExp {
  return new RegExp(
    `^${path.replace(/:([a-z]+)/gi, "(?<$1>[a-z-0-9-_]+)").replace("/", "/")}$`,
    "i"
  );
}

Enter fullscreen mode Exit fullscreen mode

The buildRegExp function, however, doesn't handle the trailing slash URLs and looks for a strict match with the path. Instead of making these functions more complicated, I suggest we instead normalize URL up in the chain in the future.

Content types

We planed to pass a special type parameter in the resolver response to determine what kind of content we want to send. We already had several "magic" properties reserved for the return value: code and errors, let's formalize responses in types:

// core/types.ts

export type SimpleTypes = string | number | boolean | null;

export type GenericResponse = {
  code?: number;
  type?: string;
};

export type ResponseData = string | Readable;

export type ErrorResponse = GenericResponse & {
  errors: Array<Error>;
};

export type AMPResponse = GenericResponse & {
  type: "amp";
  body?: string;
  head?: string;
};

export type JSONResponse = GenericResponse &
  Record<string, SimpleTypes | Record<string, SimpleTypes>>;
export type DataResponse = GenericResponse & {
  type: string;
  data: ResponseData;
  length: number;

};

export type HTMLResponse = DataResponse & {
  type: "html";
};

export type RouteResponse =
  | ErrorResponse
  | AMPResponse
  | JSONResponse
  | HTMLResponse
  | DataResponse;
Enter fullscreen mode Exit fullscreen mode

We should extract the response formatting functionality from the core middleware (formerly known as "API" middleware) into a separate file called responseFactory.ts:

// src/core/responseFactory.ts
import { ServerResponse } from "http";
import { RouteResponse } from "./types";

export default function responseFactory(res: ServerResponse) {
  return (response: RouteResponse) => {
    res.setHeader("Content-Type", "application/json");
    const code = "code" in response ? response.code : 200;
    res.statusCode = code;
    const responseText = JSON.stringify({ ...response, code });
    res.setHeader("Content-Length", responseText.length);
    res.write(responseText);
    res.end();
  };
}
Enter fullscreen mode Exit fullscreen mode

Core middleware now looks like this:

import { IncomingMessage, ServerResponse } from "http";
import cookie from "cookie";
import { APIContext, Routes, HTTPMethod } from "./types";
import requestParams from "./requestParams";
import routeRequest from "./routeRequest";
import { DataSource } from "./DataSource";
import responseFactory from "./responseFactory";

export default function core(
  modules: {
    routes: Routes;
    dataSources?: Record<string, typeof DataSource>;
  },
  context: APIContext
): any {
  return async (req: IncomingMessage, res: ServerResponse) => {
    const sendResponse = responseFactory(res);
    try {
      const method = req.method?.toUpperCase();
      // TODO: normalize url path to deal with trailing slash
      const url = new URL(req.url, "http://localhost");
      const { resolver, params: routeParams } = routeRequest(
        url,
        method as HTTPMethod,
        modules.routes
      );
      const params = await requestParams(req);
      context.cookies = req.headers.cookie && cookie.parse(req.headers.cookie);
      const dataSources = {};
      if (modules.dataSources) {
        for (let source in modules.dataSources) {
          dataSources[source] = new (modules.dataSources[source] as any)(
            context
          );
        }
      }
      const response = await resolver(
        { ...routeParams, ...params },
        { ...context, ...dataSources }
      );

      return sendResponse(response);
    } catch (error) {
      const code = "code" in error ? error.code : 500;
      const message = code >= 500 ? "Internal Server Error" : error.message;
      if (code >= 500) context.log?.error(error);
      return sendResponse({
        errors: [{ name: error["field"] ?? error.name, message }],
        code,
      });
    }
  };
}

Enter fullscreen mode Exit fullscreen mode

You've probably realized I love refactoring things and that's one of the main reasons I write so many tests first. After all these changes I can be certain that things still work just as before because all the tests are passing. Yay!

What do we do when all tests are passing? We write more tests of course (maniacal laughing):

// core/responseFactory.spec.ts
import { describe, it } from "mocha";
import { expect } from "chai";
import request from "supertest";

describe("responseFactory Integration Test", () => {
  it("can send JSONResponse");
  it("can send ErrorResponse");
  it("can send AMPResponse");
  it("can send HTMLResponse");
  it("can send DataResponse");
});

Enter fullscreen mode Exit fullscreen mode

With a neat mocha trick, we've created 5 pending tests for every type of response we've planned.

All the tests are going to be very much alike, thus I'll show only one of each type. Starting with JSON response. It will be very similar to an AMP response and Errors response test:

it("can send JSONResponse", (done) => {
    const middleware = (req, res) => {
      return responseFactory(res)({ message: "OK" });
    };
    request(middleware)
      .get("/")
      .expect(200)
      .expect("Content-Type", "application/json")
      .end((err, res) => {
        if (err) return done(err);
        expect(res.headers).to.have.property("content-length");
        expect(res.body).to.have.property("message", "OK");
        done();
      });
  });
Enter fullscreen mode Exit fullscreen mode

And a streaming data response test:

it("can send DataResponse", async () => {
    const middleware = (req, res) => {
      return responseFactory(res)({
        type: "text/plain",
        data: Readable.from(["foo", "bar"]),
        length: 6,
      });
    };
    await request(middleware)
      .get("/")
      .expect(200)
      .expect("Content-Type", "text/plain")
      .expect("Content-Length", "6")
      .expect("foobar");
  });
Enter fullscreen mode Exit fullscreen mode

The latter will fail, as we haven't yet implemented it. After several iterations, I've ended up with the following code:

// core/responseFactory.ts
import boilerplate from "amp/boilerplate";
import { ServerResponse } from "http";
import { Readable } from "stream";
import { AMPResponse, RouteResponse } from "./types";

export default function responseFactory(res: ServerResponse) {
  return async (response: RouteResponse) => {
    const code = "code" in response ? response.code : 200;
    res.statusCode = code;
    switch (response.type) {
      case "amp": {
        res.setHeader("Content-Type", "text/html");
        const responseText = boilerplate(response as AMPResponse);
        res.setHeader("Content-Length", responseText.length);
        res.write(responseText);
        return res.end();
      }
      case "json":
      case "application/json":
      case undefined:
      case null: {
        res.setHeader("Content-Type", "application/json");
        const responseText = JSON.stringify({ ...response, code });
        res.setHeader("Content-Length", responseText.length);
        res.write(responseText);
        return res.end();
      }
      case "html":
        response.type = "text/html";
      default: {
        if (!("data" in response && "type" in response)) {
          throw Error("Incorrect response");
        }
        res.setHeader("Content-Type", response.type);
        if (typeof response.data === "string") {
          res.setHeader("Content-Length", response.data.length.toString());
          res.write(response.data);
          return res.end();
        }
        if (!(response.data instanceof Readable))
          throw new Error("Unknown response data");
        if (!("length" in response))
          throw new Error(
            "Parameter length must be provided for a stream response"
          );
        res.setHeader("Content-Length", response.length.toString());
        return response.data.pipe(res);
      }
    }
  };
}

Enter fullscreen mode Exit fullscreen mode

And finally, I've adjusted authorization routes to render the login page and use Redis data:

import { APIContext } from "core/types";
import Users from "./Users";

import * as loginPage from "./pages/login";

export default {
  "/login": {
    GET: () => ({ ...loginPage, type: "amp" }),
    POST: async (
      { input: { email, password }, rid },
      { cookies, users }: APIContext & { users: Users }
    ) => {
      const user = await users.login({ email, password });
      if (cookies["amp-access"])
        users.saveToken(user.id, cookies["amp-access"]);
      return { user };
    },
  },
  "/access": {
    GET: async ({ rid }, { users }: APIContext & { users: Users }) => {
      const user = await users.byToken(rid);
      if (user) return { user, authorized: true };
      return { authorized: false };
    },
  },
  "/ping": {
    POST: (params) => {
      return { message: "OK" };
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Let's start the app and enjoy the super nerdy not found page:
{"errors":[{"name":"Error","message":"Not Found"}],"code":404}

And the 90's inspired AMP login page:
Login Form

To actually log in, we need to manually add a user to the database using redis-cli:

HSET users::usr_1 id usr_1 email clark.kent@daily.planet pwhash $2a$05$omaO8ndXQYtfSMqBsB.6SOrCTR40XDVxG09pHwKlf2eaDQdvbDRaa createdAt 0
ZADD users::email 0 clark.kent@daily.planet::usr_1
SET users::next 1
Enter fullscreen mode Exit fullscreen mode

I used the same values we generated for user tests, it creates user clark.kent@daily.planet with password 12345.

We now have everything we need to start on the actual dashboard design!

By the way, if you have any troubles with the code or just curious to know more, check out amp-cms repo if you haven't yet :

GitHub logo ValeriaVG / amp-cms

Content management system for blazingly fast AMP websites, written in TypeScript and powered by Redis

AMP CMS Logo

Maintainability Test Coverage

Content management system for blazingly fast AMP websites, written in TypeScript and powered by Redis.

AMP CMS is currently in active development. It's not ready for production use until it reaches v1.0.

Current stage: alpha

How to deploy

Note: Currenty it's not possible to automatically create Redis database for you, please do it manually and then link to the app deployment either by adding existing database in App dashboard components and setting env variable REDIS_URL to ${your-db-name.REDIS_URL}

Note: App Platform uses public paths and CMS won't run without a database, do not restrict access by api until you know it's IP address

Deploy to DO

First-time & Emergency access

You can set up a superuser account though the following environment variables:

  • SUPERUSER_EMAIL, by default is set to clark.kent@daily.planet
  • SUPERUSER_PASSWORD, by default is set to clark&lois

WARNING: consider changing the default superuser credentials

This user is never stored or rendered anywhere else…

Thank you for the company! I hope to see you again in the next article on dashboard design.

Discussion (0)