Potami
Potami (from the Greek ÏÎżÏÎŹÎŒÎč, /poh-TAH-mee/) is a lightweight, simple, declarative HTTP server for Deno.
đ« No black magic đȘ
đ« No superfluous structure đą
đ« No finger in all your pies đ„§
Potami is only as opinionated as it absolutely has to be. It gives you what you need to make an HTTP server and then gets out of your way.
Getting Started with Hello World
Starting up an HTTP server with Potami is simple. Start by creating an HttpServer
:
const server = new HttpServer();
The HttpServer
has a number of chaining methods that allow you to configure the server and bootstrap it. Configuration is done via chaining on an instance instead of being magically picked up from some arbitrarily-named config file with some arbitrary JSON format.
Once everythingâs configured, you can simply start
the server:
class HelloController extends Controller {
constructor() {
super({ base: '/hello' });
}
'GET /': RequestHandler = (req) => {
return new Response('Hello world!', { status: 200 });
}
}
const server = new HttpServer();
server
.base('/api')
.controller(new HelloController())
.start(3000);
That smidge of code produces a server that listens on port 3000 that will respond with a text response of "Hello world!"
if you send a GET
request to /api/hello
. Easy!
The Bits and Bobs
Potami attempts to only be opinionated about the things it must be to do its job. Potamiâs job is to ingest a request and get that request to a function that will handle it. Thatâs it.
You can think of Potami like a river; the request comes in, it flows through the river, and eventually it outputs a response.
Flow
Potamiâs flow is meant to be simple and predictable. When a request enters the server, the serverâs entryMiddleware
run against it. After that, Potami determines whether thereâs a controller that has a RequestHandler
that can handle the request.
If such a handler is present, Potami runs that controllerâs middleware, and ultimately calls the determined RequestHandler
to produce the Response
that Potami will give to the client.
If no handler can be found, Potami returns a default response. If you want to customize what response Potami returns when it defaults, you can use the HttpServer.defaultResponseHandler
method.
Controllers
Controllers in Potami are responsible for defining paths known to the server and how to handle them, and theyâre very important to Potamiâs job. Controllers contain RequestHandler
s that produce the Response
s that go back to the client. RequestHandler
s are named after the method and route they handle, meaning controllers handle defining your handlers and your routes all in one. Itâs far from a blackbox, thoughâknowing what paths a controller can handle is immediately evident from a simple glance at its code.
If you look in the Getting Started example above, itâs obvious what methods and paths the
HelloController
can handle. Routes mean nothing without their corresponding handlers, so Potami colocates them.
All controllers must extend Controller
:
class HelloController extends Controller {}
For every method and route your controller can handle, you specify a RequestHandler
:
class HelloController extends Controller {
constructor() {
// The base of a controller differentiates it from other
// controllers in the application and defines the base route
// for the controller.
super({ base: '/hello' });
}
'GET /': RequestHandler = (req) => {
return new Response('Hello, world!', { status: 200 });
};
'GET /json': RequestHandler = (req) => {
return new JsonResponse({ value: 'Hello, world!' }, { status: 200 });
};
'GET /greet/:name': RequestHandler = (req, { name }) => {
return new Response(`Hello, ${name}!`, { status: 200 });
}
// ...
}
Thereâs no black magic here. Specifying the RequestHandler
type is just so the types of the functionâs params are properly inferred and you donât have to type them out. You could exclude the type if you prefer.
Classes in JS are just syntactic sugar for what essentially boils down to a regular object (with some extra stuff we donât care about in this context). In JS, object properties can be any string. Potami takes advantage of this language feature to establish a convention for semantic property names. The values of these properties correspond to handlers. Potami can then parse those semantic property names to understand the routing in the application. All that without any black magic directives or anything that requires a special compilation step. And, it makes your controllers self-documenting to boot!
Importantly, the naming for a RequestHandler
is one of the few places where Potami has an opinion. Any valid HTTP method is valid for a RequestHandler
, but the handler must be named in the format METHOD /path
. The path must always start with a /
, and must always be at least /
.
Gladly, thatâs where the opinions end. What other methods, members, references, etc. that your controller has is totally up to you. Potami doesnât care.
Once youâve written your controller, you include it in your server:
const server = new HttpServer();
server.controller(new HelloController());
With that one method call, your controller and all its routes are hooked up and ready to go. Requests that come into the server will take that controller and its handlers into consideration when determining where to send the request for handling.
Middleware
Middleware is pretty handy, and Potami supports two kinds. Thereâs Entry Middleware that you can apply at the server level:
server.entryMiddleware(({ req, resHeaders }) => {...})
and thereâs Controller Middleware you can apply at the controller level:
class HelloController extends Controller {
constructor() {
super({ base: '/hello', middleware: [({ req, resHeaders }) => {...}]});
}
}
In both cases, a middleware is simply a function that receives a number of subjects and either returns nothing or returns a Response
. Among the subjects provided to a middleware are the current request and a mutable reference to a Headers
object. Middleware can be async or sync.
Setting Headers in Middleware
Your middleware can change the Headers
object it receives as needed. When the server sends the response, those headers are attached to the response. In the event multiple things touch the same header, the last item in the flow to touch the header takes precedence.
A Middlewareâs Return
A middlewareâs return can either be void
or a Response
(or a Promise
-wrapped version of those, since middleware may be sync or async). In most cases, you should prefer returning nothing from your middleware. Your server will be more predictable if you limit the number of cases you introduce into your application that cause the typical flow to fork and respond early. As much as you can, you should delegate Response
generation to your controllers.
That being said, there are use cases for letting a middleware handle a request and generate a Response
. Such cases include, but arenât limited to:
- Generating a response for a request where the path is irrelevant. A common example would be something like an
Upgrade
request sent when trying to start aWebSocket
. In this case, the path isnât necessarily relevant; your server could catch any request that includes theUpgrade: websocket
header and perform the upgrade. - Generating a response for a request that asks for information about the server. A common example would be a middleware that handles responding to
OPTIONS
requests. Such a middleware could analyze the state of theHttpServer
instance that itâs running in to determine what methods are supported by the requesterâs path. Middleware can do this because one of the subjects it receives is an immutable reference to theHttpServer
instance that the middleware is running in.
Breaking from Middleware
While you can send a Response
to break from the regular flow and respond early, you may find it more semantic to use errors in the event something is wrong with the request. If your middleware determines something isnât right, you can throw an HttpError
to break from the regular flow and have Potami respond immediately. There are a number of HttpError
subclasses available for convenience, such as BadRequestError
, ServerError
, and of course, TeapotError
. When Potami encounters an uncaught HttpError
, it will send a response to the client with a status code that reflects the thrown error. For example, throwing a BadRequestError
will yield a 400
response to the client.
Middleware Execution Order
Any middleware can be sync or async, but in either case Potami guarantees that the middleware will run in the order provided. If a particular middleware in a chain is async, Potami will wait for it to complete before moving on, even if other middleware in the chain are sync. That means in a situation like this:
server
.entryMiddleware(checkAuth, rateLimit, talkToDb, attachAppHeaders)
where talkToDb
is asynchronous, Potami will wait for talkToDb
to finish before invoking attachAppHeaders
. This improves predictability and consistency. It also allows you to put the most important things first.
In this case, we checkAuth
before doing anything else. That middleware may throw a ForbiddenError
or an UnauthenticatedError
in the event thereâs something wrong with the Authorization
on the request. Because Potami guarantees order, you can be confident that a request wonât flow through the rest of your server if it canât pass your important validation middleware.
Testability
Potami embraces testability. Because Potami has a focused set of concerns and doesnât have a lot of opinions about your application as a whole, it doesnât make a lot of assumptions. The result is that Potami doesnât try to design the world and sticks to basic, testable mechanisms. In the purest sense, Potami is a library, not a framework.
No black magic injections, providers, wiring, etc.
Making sure the elements of your Potami application work is as easy as writing unit tests against functions because the RequestHandler
s and middleware that make up an application using Potami are just functions. As a rule, Potami doesnât need any special mocking utilities because thereâs no black magic. Everything is plainly given to RequestHandler
s and middleware via function args, which means you can easily control what gets passed in a testing environment.
Testing Controllers
Controllers are just classes with methods in them. They donât have to go through any compilation to be useful. Since controllers receive the request and any route params, you can easily mock or stub out whatâs given to the controller to assert on its behaviour in a variety of situations.
In other server solutions, itâs common to use Services to move work out of your controllers. Other solutions may have a lot of opinions and structure around including Services in your application.
You can choose to have Services or not; Potami doesnât care. Potami doesnât need to know about your Services to do its job, so it doesnât need to care about them.
However, if you do choose to use Services that you invoke in your controllersâ RequestHandler
s, youâll find your controllers remain testable if you provide instances of your Services to your controllers when constructing them. That allows you to easily mock the Serviceâs instance in the tests for your controller. An example would look like:
class UserService {
getUser(userId: string) {...}
}
class UserController extends Controller {
constructor(private _userService: UserService) {
super({ base: '/users' });
}
'GET /:userId': RequestHandler = (req, { userId }) => {
const user = this._userService.getUser(userId);
// ...
};
}
When you instantiate the controller in a test suite to test it, you simply give it a mocked version of the service using your chosen mocking solution:
const mockedUserService: UserService = makeUserServiceMock();
const controller = new UserController(mockedUserService);
This isnât a requirement because, again, Potami doesnât care. This is simply given as friendly advice. If you already have a way to use Service classes/functions that you like, youâre free to do it that way.
Testing Middleware
Middleware are just functions that receive everything they need to do their job. Because of these attributes of middleware, just like testing a controllerâs RequestHandler
s, you have full control over what you give the middleware to test how it behaves in a variety of scenarios.
And thatâs it! You should find that testing an app using Potami should be a breeze. The structures Potami provides are testable by default. If you establish any additional structures, itâs up to you make them testable if thatâs something you prioritize.