npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@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".

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
        • =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

#+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