Kequapp
This is the development branch of a request listener for nodejs web apps.
It's intended to be versatile, non-intrusive, and make working with node's server capabilities easier without changing built in functionality.
Simple Setup
npm i kequapp
const { createServer } = require('http');
const { createApp } = require('kequapp');
const app = createApp();
app.route('/', () => {
return 'Hello world!';
});
createServer(app).listen(4000, () => {
console.log('Server running on port 4000');
});
Routing
Routes are defined using route()
. Method is optional default is 'GET'
, path is optional default is '/'
, followed by any number of handlers which define the request lifecycle.
Branches are defined using branch()
. Path prefix is optional default is '/'
, followed by any number of handlers. It returns a branch of the application which will adopt all handlers and use the given path prefix. By itself this does not create a route, it will be used in conjunction with routes.
Handlers are added to the current branch using middleware()
. Provide any number of handlers you would like that will affect all route and branch siblings. This is most useful on the app
instance itself.
const { Ex } = require('kequapp');
function json ({ res }) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
}
function loggedIn ({ req, context }) {
if (req.headers.authorization !== 'mike') {
throw Ex.Unauthorized();
}
context.auth = req.headers.authorization;
}
app.branch('/user')
.middleware(json)
.route(() => {
return { result: [] };
})
.route('/:id', ({ params }) => {
return { userId: params.id };
});
app.route('/admin/dashboard', loggedIn, ({ context }) => {
return `Hello admin ${context.auth}!`;
});
Renderers
Default renderers are included for text/plain
, and application/json
. Renderers are chosen based on the Content-Type
header set by your application. The above example would cause all routes of the /user
branch to trigger the application/json
renderer.
You can override renderers or add your own by defining renderers
. These act as the final step of a request's lifecycle and should explicitly finalize the response.
const app = createApp({
renderers: {
'text/html': (payload, { res }) => {
const html = myMarkupRenderer(payload);
res.end(html);
}
}
});
Halting Execution
Any handler can return a payload
. Doing this halts further execution of the request and triggers rendering immediately. This is similar to interrupting the request by throwing an error or by finalizing the response.
All processing halts if the response has been finalized. This is useful for example instead of rendering output you want to redirect the user to another page.
function members({ req, res }) {
if (!req.headers.authorization) {
res.statusCode = 302;
res.setHeader('Location', '/login');
res.end();
}
}
const membersBranch = app.branch('/members', members);
Parameters
The following parameters are made available to handlers and renderers.
parameter | description |
---|
req | The node req object. |
res | The node res object. |
url | Url requested by the client. |
context | Params shared between handler functions. |
params | Params extracted from the pathname. |
query | Params extracted from the querystring. |
getBody | Function to extract params from the request body. |
logger | Logger specified during setup. |
Body
Node delivers the body of a request in chunks. It is not always necessary to wait for the request to finish before we begin processing it. Therefore a helper method getBody()
is provided which you may use to await body parameters from the completed request.
app.route('POST', '/user', async ({ getBody }) => {
const body = await getBody();
return `User creation ${body.name}!`;
});
Multipart/Raw Body
By passing multipart
the function will return both a body
and files
.
app.route('POST', '/user', async ({ getBody }) => {
const [body, files] = await getBody({ multipart: true });
return `User creation ${body.name}!`;
});
By passing raw
the body is processed as minimally as possible, returning a single buffer as it arrived. When combined with multipart
, an array is returned with all parts as separate buffers with respective headers.
app.route('POST', '/user', async ({ getBody }) => {
const parts = await getBody({ raw: true, multipart: true });
return `User creation ${parts[0].data.toString()}!`;
});
Body Normalization
The getBody()
helper method allows you to specify which fields should be an array
and which fields are required
. This is because the server only knows a field should be an array if it received more than one. Required ensures that the field is not null
or undefined
. More control is offered using validate()
. Post processing may be applied using postProcess()
.
Note these options are ignored when the raw
option is used.
function validate (result) {
if (result.ownedPets.length > 99) {
return 'Too many pets';
}
if (result.ownedPets.length < 1) {
return 'Not enough pets!';
}
}
function postProcess (result) {
return {
...result,
name: result.name.trim(),
eyeColor: 'blue'
};
}
app.route('POST', '/user', async ({ getBody }) => {
const body = await getBody({
array: ['ownedPets'],
required: ['name'],
validate,
postProcess
});
});
Cookies
I recommend use of an external library for this.
const cookie = require('cookie');
app.middleware(({ req, context }) => {
const cookies = cookie.parse(req.headers.cookie);
context.cookies = cookies;
});
app.route('/login', ({ res }) => {
res.setHeader('Set-Cookie', [
cookie.serialize('myCookie', 'hello')
]);
});
Exceptions
Error generation is available by importing the Ex
utility. Any thrown error will be caught by the error handler and return a 500
status code, this utility enables you to easily utilize all status codes 400
and above.
These methods create errors with correct stacktraces there is no need to use new
.
const { Ex } = require('kequapp');
app.route('/throw-error', () => {
throw Ex.StatusCode(404);
throw Ex.StatusCode(404, 'Custom message', { extra: 'info' });
throw Ex.NotFound();
throw Ex.NotFound('Custom message', { extra: 'info' });
});
Exception Handling
The default error handler returns a json formatted response containing helpful information for debugging. It can be overridden by defining an errorHandler
during instantiation. The returned value will be sent to the renderer again for processing.
Errors thrown inside of the error handler or within the renderer chosen to parse the error handler's payload will cause a fatal exception.
This example sends a very basic custom response.
const app = createApp({
errorHandler (error, { res }) {
const statusCode = error.statusCode || 500;
res.statusCode = statusCode;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
return `${statusCode} ${error.message}`;
}
});
Static Files
A rudimentary staticFiles()
handler can be used to deliver files relative to your project directory. This utility makes use of the wildcards
parameter as defined by your route to build a valid path.
By default the /public
directory is used.
const { staticFiles } = require('kequapp');
app.route('/assets/**', staticFiles({
dir: '/my-assets-dir',
exclude: ['/my-assets-dir/private']
}));
If more control is needed a similar sendFile()
helper is available.
const { sendFile } = require('kequapp');
app.route('/db.json', async function ({ req, res }) {
const pathname = '/db/my-db.json';
await sendFile(req.method, res, pathname);
});
Unit Tests
It is possible to test your application without spinning up a server using the inject()
tool. The first parameter is your app, then a config override for your app, followed by options largely used to populate the request.
Returned req
and res
objects are from the npm mock-req
and mock-res
modules respectively. Ensure you have both mock-req
and mock-res
installed in your project.
It also returns getResponse()
which is a utility you may use to wait for your application to respond. Alternatively you may inspect what your application is doing in realtime using the req
, and res
objects manually.
const assert = require('assert');
const { inject } = require('kequapp/test');
it('reads the authorization header', async function () {
const { getResponse, res } = inject(app, { logger }, {
url: '/admin/dashboard',
headers: {
Authorization: 'mike'
}
});
const body = await getResponse();
assert.strictEqual(res.getHeader('Content-Type'), 'text/plain; charset=utf-8');
assert.strictEqual(body, 'Hello admin mike!');
});
A body
parameter can optionally be provided for ease of use. All requests are automatically finalized when they are initiated with inject()
unless you set body
to null
. Doing so will allow you to write to the stream.
The following two examples are the same.
it('reads the body of a request', async function () {
const { getResponse, res } = inject(app, { logger }, {
method: 'POST',
url: '/user',
headers: {
'Content-Type': 'application/json; charset=utf-8'
},
body: '{ "name": "april" }'
});
const body = await getResponse();
assert.strictEqual(body, 'User creation april!');
});
it('reads the body of a request', async function () {
const { getResponse, req, res } = inject(app, { logger }, {
method: 'POST',
url: '/user',
headers: {
'Content-Type': 'application/json; charset=utf-8'
},
body: null
});
req.end('{ "name": "april" }');
const body = await getResponse();
assert.strictEqual(body, 'User creation april!');
});