@apparts/prep
v2.5.1
Published
Typechecking for express requests
Downloads
19
Readme
#+TITLE: @apparts/prep #+DATE: [2019-08-26 Mon] #+AUTHOR: Philipp Uhl
This package provides type-checking for [[https://www.npmjs.com/package/express][express]] servers, test helpers and documentation generation. The package has full Typescript support.
The type definitions used are from [[https://github.com/apparts-js/apparts-types][@apparts/types]].
- Configuration
Under the configuration name =types-config= the following options exist:
- ~bugreportEmail {string}~ :: An email address that will be shown in case of a bug.
- ~idType {string}~ :: Can be one of
- ~string~
- ~UUIDv4~
- ~int~
- A regular expression
More information on how to set this: [[https://github.com/apparts-js/apparts-config][@apparts/config]].
- Request Encoding
When requesting an API checked by @apparts/prep, make sure, the following holds:
- The body is always expected to be in JSON format.
- The path parameters must never be empty (otherwise express can't route you correctly) and if the type used is an array, it must be JSON encoded.
- The param and query parameters must be URI encoded. If the =typeof= gives ="object"= on the value, the value must be JSON encoded, before URI encoding.
- Usage
The =prepare= function provides a wrapper around express routes. It checks the types of the requests and handles errors.
The =prepare= function takes these arguments:
- =options = ::
- =title = :: The title of the route (for documentation).
- =?description = :: A description of the route (for documentation).
- =receives= :: The Format the request has to be in, to be accepted. The =body=, =query=, and =param= fields are optional and take key-value pairs where the values are types as described through type definitions according to [[https://github.com/apparts-js/apparts-types][@apparts/types]].
- =returns = :: All potential types that can be returned by the function (for documentation and for validation). More information in the section "Test API Types".
- hasAccess :: A functions that defines access rights for the individual endpoint. It receives the same parameters as a request handler (see =route=). If the function throws an =HttpError=, access will be rejected. If another error is thrown, a 500 error will be produced. Otherwise access will be granted. The return value of this function will be passed as third parameter to the =route= function. More info below in section Access control.
- =route <(request, response, accessControlResult) =-> Promise>= :: A (async) function that receives as first parameter the request object from express, but the =body=, =query=, =params= are parsed and type checked. What the function returns will be returned to the client. If the function throws an =HttpError=, the result code is set accordingly and the =HttpError.message= will be returned. Also =HttpCode= and =DontRespond= can be returned, see below. If the function throws anything else, a 500 server error will be produced.
#+BEGIN_SRC js const { boolean, value, obj, int, objValues } = require("@apparts/types"); const { prepare, HttpError, httpErrorSchema, anybody } = require("@apparts/prep");
const myEndpoint = prepare(
{
title: "Testendpoint for multiple purposes",
description: Behaves radically different, based on what
the filter is.
,
hasAccess: anybody, // everybody has access
receives: {
body: obj({
name: string().default("no name").description("A name"),
}),
query: obj({
filter: string().optional(),
number: int().default(0),
}),
params: obj({
id: int().semantic("id"),
}),
},
returns: [
value("ok"),
httpErrorSchema(400, "Name too long"),
obj({
foo: value("really!").description("Some text"),
boo: boolean(),
kabaz: boolean().optional(),
arr: array(
obj({
a: int(),
c: obj({
d: int(),
}).optional(),
e: int().optional(),
}).description("Some array item text")
).description("This is an array"),
objectWithUnknownKeys: objValues(int()).description(
"Quod illo quos excepturi alias qui. Illo non laudantium commodi. Est quos consequatur debitis in. Iusto fugiat sunt sit. Dolorem quod eius sit non."
),
objectWithUnknownKeysAndUnknownTypes: objValues(any()),
}),
],
},
async ({ body: { name }, query: { filter }, params: { id } }, res, accessControlResult) => {
if (name.length > 100) {
new HttpError(400, "Name too long");
}
// filter might not be defined, as it is optional
if (filter) {
// Return values are JSONified automatically!
const resp = {
arr: [{ a: 1 }, { a: 2 }],
foo: "really!",
boo: true,
objectWithUnknownKeys: {
baz: filter === "asstring" ? "77" : 77,
boo: 99,
},
objectWithUnknownKeysAndUnknownTypes: {
baz: 77,
boo: false,
},
};
if (filter === "kabazplz") {
resp.kabaz = false;
}
return resp;
}
// This produces "ok" (literally, with the quotes)
return "ok";
});
module.exports = { myEndpoint }; // app.post("/v/1/endpoint/:id", myEndpoint); #+END_SRC
** Sending other status codes then 200
Within the =route= function you can use the =HttpCode= class as follows:
#+BEGIN_SRC js const { prepare, HttpCode, httpCodeSchema } = require("@apparts/prep"); const { obj, string } = require("@apparts/types");
const myEndpoint = prepare({ title: "Endpoint that handles responding", /* ...*/ returns: [httpCodeSchema(304, obj({ "whatever": string() }))], }, async () => { const myData = { "whatever": "i want" }; return new HttpCode(304, myData); }); #+END_SRC
** Sending HttpErrors
Within the =route= function you can use the =HttpError= class as follows:
#+BEGIN_SRC js const { prepare, HttpError, httpErrorSchema } = require("@apparts/prep"); const { obj, string } = require("@apparts/types");
const myEndpoint = prepare({ title: "Endpoint that handles responding", /* ...*/ returns: [ httpErrorSchema(403, "Nope"), httpErrorSchema(400, "You specified that parameter wrong"), httpErrorSchema(404, "My element not found"), httpErrorSchema(412, "Want to throw"), ], }, async () => { return new HttpError(403, "Nope"); // --> http-statuscode 403, body: { }
// error with description
return new HttpError(
400,
"You specified that parameter wrong",
"Some dynamic info: " + somethingWrong);
// --> http-statuscode 400, body: {
// error: "You specified that parameter wrong",
// description: "Some dynamic info: blub"
// }
// can be thrown
throw new HttpError(412, "Want to throw");
// --> http-statuscode 412, body: { error: "Want to throw" }
}); #+END_SRC
You can return or throw an error.
** Sending a response manually
Sometimes you want to handle the response yourself. In these cases you can tell prepare to not send for you, using the =DontRespond= class.
Keep in mind that the prepare already did these calls for you: #+BEGIN_SRC js res.setHeader("Content-Type", "application/json"); res.status(200); #+END_SRC
If you want other values, overwrite them by making the respective calls on =res= yourself.
#+BEGIN_SRC js const { prepare, DontRespond } = require("@apparts/prep");
const myEndpoint = prepare({ title: "Endpoint that handles responding", /* ...*/ }, async (req, res) => { // handle send by yourself res.send(); return new DontRespond(); }); #+END_SRC
If you use =res.send()= without returning an =DontResond= instance, you will see error messages, that express cannot send after the response has already been send.
** Error handling by =preperator=
- Should a request not match any of the type assertions as defined, the =prepare= will respond with a status code of 400 and this body: #+BEGIN_SRC json { "error": "Fieldmissmatch", "description": "" } #+END_SRC
- Should the route throw an error that is not an [[https://github.com/phuhl/apparts-error][HttpError]], it catches
the error and returns with a status code of 500 and this body
(encoding: =text/plain=):
#+BEGIN_EXAMPLE
SERVER ERROR! Please consider sending this error-message along with a description of what happend and what you where doing to this email-address: <config.bugreportEmail>
#+END_EXAMPLE
Additionally a more complete error will be logged:
- The error that was thrown will be logged as is.
- A JSON encoded object (for automated collecting of errors) with
these fields:
- ID :: A Uuid v1 (that is the same as was returned to the client) for matching client-side errors with errors in the log.
- USER :: The =Authorization= header
- TRACE :: The stack trace of the error
- REQUEST :: Object with
- url :: The requesting url
- method :: HTTP method used (e.g. POST)
- ip :: Ip of client
- ua :: User agent of client
- If you want to control how the error is logged, pass to the prepare options a function as ~logError: (msg: string, req: ExpressRequest, res: ExpressResponse) => void~
** Access Control
In the previous examples, all routes were created accessible for anybody. That is most likely not what you want.
The =prepare= function expects a =hasAccess= option that must handle authentication and authorization. This function receives all parameters of the API-call and uses them to determine if access should be granted.
The return behavior of the function defines if access is granted or not:
- If it throws or returns an =HttpError=, access will be rejected. The corresponding error will be returned.
- If it throws or returns an =HttpCode=, access will be rejected. The corresponding code will be returned.
- If it throws or returns =DontRespond=, access will be rejected. Nothing will be returned.
- If another error is thrown, access will be rejected and a 500 error will be returned.
- If it resolves, access will be granted. The result will be passed as a third argument to the endpoint function.
The function can be synchronous or =async=.
These access functions add additional response types to the API that you might want to document. To do that, you can specify a description and a response schema using the =accessFn= helper function.
#+BEGIN_SRC js import { HttpError, httpErrorSchema, accessFn } from "@apparts/prep";
const isMine = accessFn({ fn: ({ params: { userid } }, jwt) => { if(userid !== jwt.userid) { // manually throw an HttpError throw new HttpError(403, "You can only access your own resources"); } }, description: "is mine", // telling the documentation, what this function might throw. returns: [httpErrorSchema(403, "You can only access your own resources")] }); #+END_SRC
The =anybody= helper function will not throw and can be used if you want to allow anybody to access then endpoint.
The =rejectAccess= helper function throws an =HttpError= 403 with a generic error message. It can be used directly as an access function (which has limited use) or as a helper when building your own access functions to produce a consisted generic error message:
#+BEGIN_SRC js import { anybody, rejectAccess, accessFn, jwtAnd as _jwtAnd } from "@apparts/prep";
const isOfFriend = accessFn({ // first parameter of fn: the request with the database and the // parsed parameters as you would get from @apparts/prep fn: async ({ dbs, params: { userid } }, jwt) => { // I can only list comments from my friends const [,User] = useUser(dbs); const meUser = await new User().loadById(jwt.userid); if(meUser.content.friends.indexOf(userId) === -1) { // using the rejectAccess helper to throw generic 403 rejectAccess(); } }, description: "is of my friend", // telling the documentation, what this function might throw. returns: [rejectAccess.returns] }); #+END_SRC
If you want to authenticate a user, you will first have to do the authentication based on the request.
The access function receives all the request parameters as it's first parameter. It includes the headers and can use these to authenticate the request.
E.g. when using a JSON Web Token (JWT) to authenticate a user, you can use the =jwtAnd= helper function. It takes the JWT secret as parameter and returns a validation function that will parse the JWT from the =Authorization= header that uses the format =Bearer =. The returned validation function accepts as parameters other validation functions that will receive the request as first parameter and the parsed JWT as second parameter. See the example below.
You can of course implement your own authentication functions for parsing other authentication schemes.
Here an example, putting it together:
#+BEGIN_SRC js import { HttpError, httpErrorSchema, anybody, rejectAccess, accessFn, jwtAnd as _jwtAnd } from "@apparts/prep";
const jwtAnd = _jwtAnd("");
const isOfFriend = accessFn({ // first parameter of fn: the request with the database and the // parsed parameters as you get in the endpoint handler fn: async ({ dbs, params: { userid } }, jwt) => { // I can only list comments from my friends const [,User] = useUser(dbs); const meUser = await new User().loadById(jwt.userid); if(meUser.content.friends.indexOf(userId) === -1) { // using the rejectAccess helper to throw generic 403 rejectAccess(); } }, description: "is of my friend", // telling the documentation, what this function might throw. returns: [rejectAccess.returns] });
const isMine = accessFn({ fn: ({ params: { userid } }, jwt) => { if(userid !== jwt.userid) { // manually throw an HttpError throw new HttpError(403, "You can only access your own resources"); } }, description: "is mine", // telling the documentation, what this function might throw. returns: [httpErrorSchema(403, "You can only access your own resources")] });
app.get("/v/1/user/:userid/comment", prepare({ title: "Endpoint that gives access to all comments of a users friend", hasAccess: jwtAnd(isOfFriend) /* .../ }, async (req, res, jwtContent) => { / ... */ }));
app.get("/v/1/comment/:commentid", prepare({ title: "Endpoint that returns a comment by id. Anybody who has the id and a valid JWT can retrieve it.", hasAccess: jwtAnd(anybody) /* .../ }, async (req, res, jwtContent) => { / ... */ }));
app.post("/v/1/user/:userid/comment", prepare({ title: "Endpoint to post comments. User can post comments under own user account only.", hasAccess: jwtAnd(isMine) /* .../ }, async (req, res, jwtContent) => { / ... */ })); #+END_SRC
For convenience some helpers are defined that support combining multiple access decider functions:
#+BEGIN_SRC js const { addCrud, or, orS, anybody } = require("@apparts/model");
const isAdmin = accessFn({ fn: (request, jwt) => /* ... / }) const isUser = accessFn({ fn: (request, jwt) => / ... / }) const canListComments = (ps) => accessFn({ fn: (request, jwt) => / ... */ });
app.post("/v/1/user/", prepare({ hasAccess: { hasAccess: andJwt(isUser) }, /* .../ }, async (req, res, jwtContent) => { / ... */ }));
app.put("/v/1/user/", prepare({ hasAccess: { hasAccess: andJwt(or(isUser, isAdmin)) }, /* .../ }, async (req, res, jwtContent) => { / ... */ }));
app.get("/v/1/user/:id/comments", prepare({ hasAccess: { hasAccess: andJwt(orS(isAdmin, and(isUser, canListComments))) }, /* .../ }, async (req, res, jwtContent) => { / ... */ }));
#+END_SRC
The helper functions are:
#+BEGIN_SRC js // check all conditions in parallel const and = (...fs) => async (...params) => Promise const or = (...fs) => async (...params) => Promise
// check all conditions in sequence const andS = (...fs) => async (...params) => Promise const orS = (...fs) => async (...params) => Promise
// anybody const anybody = () => Promise.resolve();
// nobody const rejectAccess = () => { throw new HttpError(403, "You don't have the rights to retrieve this.") }
// define an access function with a description and the possible http // responses for documentation purposes const accessFn = (params: { fn: FnType; description?: string; returns?: ReturnsArray; }) => FnType;
// For checking a JWT and then checking conditions const jwtAnd = (webtokenkey: string) => (...fs) => async (...params) => Promise; #+END_SRC
- Generate API documentation
Create a file =genApiDocs.js=: #+BEGIN_SRC js const addRoutes = require("./routes"); const express = require("express"); const { genApiDocs: { getApi, apiToHtml, apiToOpenApi }, } = require("@apparts/prep");
const app = express(); addRoutes(app);
const docs = apiToHtml(getApi(app));
// Also available: docs in the open api format //const openApiDocs = apiToOpenApi(getApi(app));
console.log(docs); #+END_SRC
Then, run:
#+BEGIN_SRC sh node genApiDocs.js > api.html #+END_SRC
See your Api-documentation in the generated =api.html= file.
- Test API Types
Use =checkType= to check that the returned data has the format that you expect. Use =allChecked= to make sure, that all of your type definitions have occurred at least once in your tests.
For =checkType=, you need to define a type definition for your endpoint. You do that by assigning a =returns= array to the endpoint function like shown above. The =returns= has the form of:
Object with:
- status :: Expected status code
- One of
- error :: Expected error text, as returned by =HttpError= from the
"@apparts/error" package
- When an error key is used, the response will exclude the field =description= of the response body from the check. This allows to optionally put dynamic content into the =description= field, to elaborate further on the error
- type :: A type as described in Section "Types".
- error :: Expected error text, as returned by =HttpError= from the
"@apparts/error" package
Functions:
- =useChecks : <(functionContainer) => { checkType, allChecked}>= ::
Returns the functions needed to perform checks
- Parameters:
- =funktionContainer= :: An object that contains the tested function under the key as specified in =functionName=
- Returns:
- Object with keys:
- =checkType : <(response, functionName, options) => boolean>= :: Checks if
type is allowed.
- Parameters:
- =response= :: The response, that should be checked
- =functionName = :: The name of the function
- =options = :: Optional options object
- =explainError = false= :: If true, prints an explanation on error.
- Returns:
- =true= :: Check passed
- Throws:
- An Error when checks have not passed
- Parameters:
- =allChecked : <(functionName) => boolean>= :: Check if all
possible return combinations have been checked
- Parameters:
- =functionName = :: The name of the function
- Returns:
- =true= :: All possible return combinations for the given function have been tested
- Throws:
- An Error when checks have not passed
- Parameters:
- =checkType : <(response, functionName, options) => boolean>= :: Checks if
type is allowed.
- Object with keys:
- Parameters:
#+BEGIN_SRC js const { useChecks } = require("@apparts/prep"); const request = require("supertest");
const myEndpoint = require("./myEndpoint");
const { checkType, allChecked } = useChecks(myEndpoint); ///const app = ...;
describe("myEndpoint", () => { const functionName = "myEndpoint"; test("Test with default name", async () => { const response = await request(app).post("/v/1/endpoint/3"); checkType(response, functionName); expect(response.statusCode).toBe(200); expect(response.body).toBe("ok"); }); test("Test with too long name", async () => { const response = await request(app).post("/v/1/endpoint/3") .send({ name: "x".repeat(200) }); checkType(response, functionName); expect(response.statusCode).toBe(400); }); test("Test with filter", async () => { const response = await request(app).post("/v/1/endpoint/3?filter=4"); checkType(response, functionName); expect(response.statusCode).toBe(200); expect(response.body).toMatchObject({ arr: [{ a: 1 }, { a: 2}], boo: true }); }); test("All possible responses tested", () => { allChecked(functionName); }); }); #+END_SRC