/bed

Bed is a simple, opinionated library for writing RESTful APIs for Deno.

Primary LanguageTypeScript

Bed

Bed is a simple, opinionated library for writing RESTful APIs for Deno. It comes with several optional add-ons and does allow for custom configuration, if you are handy with the Deno HTTP API.

Core

The core export (/core.ts) is the Server class. Start a server like so:

import { Server } from 'bed/core.ts';
import handlers from './api.ts';

const server = new Server(handlers);
await server.listen(8080);

handlers should be a tree describing the routes, middleware, and endpoints of your server. Unlike more sophisticated server libraries like Express, Koa, or Oak, you cannot dynamically load middleware or endware after initializing a server -- the argument to new Server should fully specify the API.

Suppose we want to define four endpoints for our server: GET /, GET /users, POST /users, and GET /users/admins. We would then define our handlers like so:

// api.ts
import { GET, POST } from 'bed/core.ts';

// import our endware funcs
import getRoot from './handlers/getRoot.ts';
import getUsers from './handlers/getUsers.ts';
import postUser from './handlers/postUser.ts';
import getAdmin from './handlers/getAdmin.ts';

export default {
	[GET]: getRoot,
	users: {
		[GET]: getUsers,
		[POST]: postUser,
		admins: {
			[GET]: getAdmins
		}
	}
}

Endware funcs should be functions of type (context: Context) => Promise<void>, where Context is defined as:

interface Context {
	query: Record<string, unknown>
	params: Record<string, string>
	err: Error | null
	extras: Record<string, unknown> // middleware can attach arbitrary data here
	requestEvent: Deno.RequestEvent // the underlying request event
	server: Server // reference to the server
	get processedUrl(): {
		path: string[] // if the relative url is '/a/b/c', path === ['a', 'b', 'c']
		queryString: string
		pathString: string // relative url, get absolute url from requestEvent
		fullPathString: string // === `${pathString}?${queryString}`
	}

	getBody: () => Promise<unknown>
	next: (err?: Error) => Promise<void>

	res(): Promise<void> // sends 204, no data
	res(status: number)
	res(data: Sendable) // sends 200, with data
	res(status: number, data: Sendable)
}

type Sendable = string | boolean | null | (Sendable | number)[] | {
  [key: string]: (Sendable | number);
};

So an example handler might look like this:

// handlers/getUsers.ts
import type { Context } from 'bed/core.ts';
import userDbFunc from './models/Users';

export default async function getUsers({ query, res } : Context) {
	const users = await userDbFunc(query);
	await res(users);
}

For extended context types, parametric routes, middleware, fallback handlers, error handling, body parsing, and customization see the following subsections.

Extend Context Types

Middleware my attach additional properties to the context object. Declare the extended type of the context object when you instantiate the server like so:

import { Server, Context, USE } from 'bed/core.ts';

interface MyContext extends Context {
	myValue: string
}

function attachMyValue(ctx: MyContext) {
	ctx.myValue = "hello!";
	ctx.next();
}

await new Server<MyContext>({
	[USE]: [logging, attachMyValue]
}).listen(8080);

Parametric Routes

Here's how to add GET /users/:userId to our router.

// api.ts
import { GET, POST, PARAM, ALIAS } from 'bed/core.ts';

// import our endware funcs
import getRoot from './handlers/getRoot.ts';
import getUsers from './handlers/getUsers.ts';
import postUser from './handlers/postUser.ts';
import getAdmin from './handlers/getAdmin.ts';
import getUserDetail from './handlers/getUserDetail.ts';

export default {
	[GET]: getRoot,
	users: {
		[GET]: getUsers,
		[POST]: postUser,
		admins: {
			[GET]: getAdmins
		},
		[PARAM]: {
			[ALIAS]: "userId",
			[GET]: getUserDetail
		}
	}
}

And here's how to access the parametric value:

// handlers/getUsers.ts
import type { Context } from 'bed/core.ts';
import userDbFunc from './models/Users';

export default async function getUsers({ params: { userId }, res } : Context) {
	const users = await userDbFunc({ where: userId });
	await res(users);
}

Middleware

To specify middleware for one specific endpoint, provide an array of functions instead of just one.

// api.ts
import { GET, POST } from 'bed/core.ts';

// import our endware funcs
import getRoot from './handlers/getRoot.ts';
import getUsers from './handlers/getUsers.ts';
import postUser from './handlers/postUser.ts';
import getAdmin from './handlers/getAdmin.ts';
import adminOnly from './handlers/auth/adminOnly.ts';

export default {
	[GET]: getRoot,
	users: {
		[GET]: [adminOnly, getUsers],
		[POST]: [adminOnly, postUser],
		admins: {
			[GET]: [adminOnly, getAdmins]
		}
	}
}

Like with other libraries, middleware should call next to pass control to other middleware or endware.

// handlers/auth/adminOnly.ts

export default function adminOnly({ res, next }: Context) {
	// ...authenticate somehow
	if (authenticated) {
		await next();
		await logActionsTakenByAdmin();
	} else {
		await res(401);
	}
}

We can improve on the example above, however, since we want to use the same middlware for all endpoints on the /users router:

// api.ts
import { GET, POST, USE } from 'bed/core.ts';

// import our endware funcs
import getRoot from './handlers/getRoot.ts';
import getUsers from './handlers/getUsers.ts';
import postUser from './handlers/postUser.ts';
import getAdmin from './handlers/getAdmin.ts';
import adminOnly from './handlers/auth/adminOnly.ts';

export default {
	[GET]: getRoot,
	users: {
		[USE]: [adminOnly],
		[GET]: getUsers,
		[POST]: postUser,
		admins: {
			[GET]: getAdmins
		}
	}
}

Fallback Handlers

Bed does not support wildcard handlers, but you can define endpoints which will be accessed if no other endpoint matches. Let's add one such fallback handler to the routes we defined above:

// api.ts
import { GET, POST, FALLBACK } from 'bed/core.ts';

// import our endware funcs
import getRoot from './handlers/getRoot.ts';
import getUsers from './handlers/getUsers.ts';
import postUser from './handlers/postUser.ts';
import getAdmin from './handlers/getAdmin.ts';
import notFound from './handlers/notFound.ts';

export default {
	[GET]: getRoot,
	users: {
		[FALLBACK]: notFound,
		[GET]: getUsers,
		[POST]: postUser,
		admins: {
			[GET]: getAdmins
		}
	}
}

Our notFound handler will fire on any request whose relative url starts with /users... that is not defined, including anything that starts with /users/admins..., since we haven't defined a fallback handler specifically for the admins router. But a request to /usres will not hit our notFound handler since we did not attach it at the root level. (In this case, the default fallback handler will be used, which simply sends status 404 with no body.)

Error Handling

Similar to fallback handlers, error handling endware can be defined for individual routers, or globally if attached to the root. Here we defined an error handler for the whole API:

// api.ts
import { GET, POST, ERROR } from 'bed/core.ts';

// import our endware funcs
import getRoot from './handlers/getRoot.ts';
import getUsers from './handlers/getUsers.ts';
import postUser from './handlers/postUser.ts';
import getAdmin from './handlers/getAdmin.ts';
import errorHandler from './handlers/error.ts';

export default {
	[ERROR]: errorHandler,
	[GET]: getRoot,
	users: {
		[GET]: getUsers,
		[POST]: postUser,
		admins: {
			[GET]: getAdmins
		}
	}
}
// handlers/error.ts

export default function errorHandler({ err, res, processedUrl }: Context) {
	console.error(`There was an error at ${processedUrl.fullPathString}:`, err);
	res(500, err.message);
}

To use error handling endware, simply throw an error from inside a handling function, and it will be passed in to the nearest error handling endware in the API tree. You can also call next passing in an error, but this will simply immediately throw the error, adding one extra call to the call stack.

// handlers/getUsers.ts
import type { Context } from 'bed/core.ts';
import userDbFunc from './models/Users';

export default async function getUsers({ query, res } : Context) {
	const users = await userDbFunc(query);
	if (!users.length) {
		throw new Error(`No users found for search ${JSON.stringify(query)}`);
	}
	await res(users);
}

The core module also exposes an Error subclass called PublicError which takes a status code argument. Throwing one of these errors will cause all error-handling to be bypassed and for the error message to be sent as the response with the attached status code.

// handlers/getUsers.ts
import type { Context } from 'bed/core.ts';
import { PublicError } from 'bed/core.ts';
import userDbFunc from './models/Users';

export default async function getUsers({ query, res } : Context) {
	const users = await userDbFunc(query);
	if (!users.length) {
		throw new PublicError(404, `No users found for search ${JSON.stringify(query)}`);
		// ^ equivalent to `return res(404, `No users found...`)`
	}
	await res(users);
}

Finally, if no error handler is loaded, by default any errors thrown from middleware or endware handlers result in a 500 response with no body.

Body Parsing

By default, Bed parses request bodies as JSON. Access the body by calling getBody.

// handlers/postUser.ts
import type { Context } from 'bed/core.ts';
import createUserDb from './models/Users';

export default async function postUser({ getBody, res } : Context) {
	const body = await getBody();
	const newUser = await createUserDb(query);
	await res(users);
}

Note that parsing begins eagerly as soon as the request is recieved. But any errors (like if the body is not JSON) are deferred until getBody is called.

To disable JSON body parsing, pass an extra option to new Server.

import { Server } from 'bed/core.ts';
import handlers from './api.ts';

const server = new Server(handlers, { jsonBody: false });
await server.listen(8080);

Customization

To customize the behavior of your Bed server, you can load middleware that that intercepts the raw Deno.RequestEvent before any processing (e.g., the creation of the context object that gets passed along). If this middleware returns true, it will short-circuit and undergo no further processing. If it returns false, it will be passed into the normal handling sequence.

import { Server } from 'bed/core.ts';
import handlers from './api.ts';
import { myLowLevelMiddleware } from './utils.ts'

const server = new Server(handlers, { prehandlers: [myLowLevelMiddleware] });
await server.listen(8080);

Supplementary Modules

The library exposes several supplementary modules which can be imported as needed. These have yet to be documented but all expose fairly simple APIs.

Cookies

// server.ts
import { CookiesContext, cookies } from 'bed/cookie.ts';
import type { Context } from 'bed/core.ts';

export type Ctx = Context & CookiesContext;

await new Server<Ctx>({
  [USE]: [cookies],
  auth: {
    login,
    me
  }
}).listen(8080)

// login.ts

export async function login(ctx: Ctx) {
  // ...
  ctx.addCookie({
    name: "mySession",
    value: "jk32jfk49134",
    expires,
  })
  // ...
}

// me.ts

export async function me(ctx: Ctx) {
  const { cookies: { mySession } } = ctx;
  // ...
}

Here is the CookiesContext exported by cookie.ts:

import type { Cookie } from 'https://deno.land/std@0.125.0/http/cookie.ts'

export interface CookiesContext {
	cookies: Record<string, string>
	addCookie: (cookie: Cookie) => void;
	outgoingCookies: Cookie[]
 }

Static

Just a thin wrapper around https://deno.land/x/static_files@1.1.6/mod.ts.

import { serveStatic } from 'bed/static.ts';

await new Server({
  [USE]: [serveStatic("..", "public")],
  auth: {
    login,
    me
  }
}).listen(8080)

Logger

import { logging } from 'bed/logger.ts';

await new Server({
  [USE]: [logging],
  auth: {
    login,
    me
  }
}).listen(8080)

Validator