/pointyapi

Stop writing endpoints.

Primary LanguageTypeScriptMIT LicenseMIT

PointyAPI

"Stop writing endpoints"

Typescript RESTful Server Architecture

Build Status Coverage Status.

Created and maintained by Stateless Studio

Introduction

PointyAPI is a library for quickly creating robust API servers.

  • ORM (TypeORM) Create models which automatically create and maintain your database
  • Validation (Class Validator) Use Typescript decorators to automatically validate fields
  • Authentication (JWT) JWT and request.user make authorization a breeze
  • Authorization Use Guards, CanRead(), and CanWrite() fields to ensure the user can read/write specific fields

Models

Models are created as Typescript classes. Here's an example class for a basic user:

@Entity()
class User extends BaseUser
{
	// User ID
	@PrimaryGeneratedColumn() // Primary column
	@IsInt() // Validation - Value must be integer
	@BodyguardKey() // Authentication - User must match this to be considered "self"
	@AnyoneCanRead() // Authorization - Anyone is allowed to read this field
	public id: number = undefined;

	// Username
	@Column({ unique: true }) // Database column - Usernames are unique
	@IsAlphanumeric() // Validation - must be alphanumeric
	@Length(4, 16) // Validation - must be between 4-16 characters
	@AnyoneCanRead() // Authentication - Anyone has read privelege to this member
	@OnlySelfCanWrite() // Authorization - Only self can write this member
	public username: string = undefined;

	// Password
	@Column() // Database column
	@OnlySelfCanWrite() // Authentication - Only self can write this member
	// No read key - nobody can read this field
	public password: string = undefined;

	/**
	 * hashPassword
	 */
	public hashPassword() {
		if (this.password) {
			this.password = hashSync(this.password, 12);
		}
	}

	/**
	 * Hash the user's password before post
	 */
	public async beforePost(request: Request, response: Response) {
		this.hashPassword();

		return true;
	}

	/**
	 * Hash the user's password on update
	 */
	public async beforePatch(request: Request, response: Response) {
		this.hashPassword();

		return true;
	}
}

You can check out more sample models in examples/, or read more about them in the Models documentation.

Routes

Routes are Express routes which chain together PointyAPI middleware functions. Here's an example User router:

const router: Router = Router();

/**
 * Load model into PointyAPI
 */
async function loader(request, response, next) {
	if (await setModel(request, response, User)) {
		next();
	}
}

// POST - Load model, filter members, and save
router.post('/', loader, postFilter, postEndpoint);

// GET - Load model, filter members, and send
router.get('/', loader, getFilter, getEndpoint);

// PATCH - Load model, check if user owns, filter members, and save
router.patch(`/:id`, loader, onlySelf, patchFilter, patchEndpoint);

// DELETE - Load model, check if user owns, and delete
router.delete(`/:id`, loader, onlySelf, deleteEndpoint);

export const userRouter: Router = router;

Getting Started

Prerequisites

  • NodeJS
  • Database (Postgres Recommended)
  • HTTP Testing Client (Postman, cUrl, etc)
  • TS-Node
  • npm i -g ts-node

Install

Create a folder for your project, and run these commands:

cd my-project
npm init -y
npm i pointyapi

Create project

  1. Create a source directory, src

  2. Inside src/, create a file index.ts

    // src/index.ts
    
    import { pointy } from 'pointyapi';
    import { basicCors, loadUser } from 'pointyapi/middleware';
    const ROOT_PATH = require('app-root-path').toString();
    
    // Routes
    // TODO: We will import routes here
    
    // Setup
    pointy.before = async (app) => {
    	// CORS
    	app.use(basicCors);
    
    	// Load user
    	app.use(loadUser);
    
    	// Routes
    	// TODO: We will set our routes here
    
    	// Database
    	await pointy.db
    		.setEntities(
    			[
    				/* TODO: We will set our models here */
    				ExampleUser
    			]
    		)
    		.connect(ROOT_PATH)
    		.catch((error) => pointy.error(error));
    };
    
    // Start the server!
    pointy.start();
  3. Create a user route

    By default, PointyAPI will use ExampleUser as the user model. Let's create a route for this model, so that we can access this model through our API:

    • Create a folder for routes, src/routes/
    • Create a new router file, src/routes/user.ts
    • Copy & paste router code:
    // src/routes/user.ts
    
    import { Router } from 'express';
    import { setModel } from 'pointyapi';
    import { ExampleUser } from 'pointyapi/models';
    import { postFilter, getFilter, patchFilter } from 'pointyapi/filters';
    import { onlySelf } from 'pointyapi/guards';
    import {
    	getEndpoint,
    	postEndpoint,
    	patchEndpoint,
    	deleteEndpoint
    } from 'pointyapi/endpoints';
    
    const router: Router = Router();
    
    // Set the route model to ExampleUser
    async function loader(request, response, next) {
    	if (await setModel(request, response, ExampleUser)) {
    		next();
    	}
    }
    
    // Route endpoints
    router.get('/', loader, getFilter, getEndpoint);
    router.post('/', loader, postFilter, postEndpoint);
    router.patch(`/:id`, loader, onlySelf, patchFilter, patchEndpoint);
    router.delete(`/:id`, loader, onlySelf, deleteEndpoint);
    
    export const userRouter: Router = router;
  4. Import user route into server

    Open src/index.ts up again, and let's import our new User route.

    import { ExampleUser } from 'pointyapi/models'; // Add import to our user model
    
    ...
    // Routes
    // TODO: We will import routes here
    import { userRouter } from './routes/user'; // Add import to our user route
    
    ...
    
    // Routes
    // TODO: We will set our routes here
    app.use('/api/v1/user', userRouter); // Add our user route to the app
    
    // Database
    await pointy.db
    	.setEntities(
    		[
    			/* TODO: We will set our models here */
    			ExampleUser // Add our BaseModel model to the database
    		]
    	)
    
    ...
  5. Setup package.json

    Add a start script to package.json:

    "scripts": {
    	"start": "ts-node src/index.ts"
    }
  6. Setup database

    Create a database, create a local.config.json file in the root folder of your app, and replace the values:

    {
    	"type": "postgres",
    	"host": "localhost",
    	"port": 5432,
    	"user": "MY_DATABASE_USERNAME",
    	"password": "MY_DATABASE_PASSWORD",
    	"database": "MY_DATABASE_NAME"
    }
  7. Start & Test

    Our server is ready to run:

    npm start
    

    Open Postman, and send a GET request for all users. You'll see the result as an empty array, as there are no users yet: postman

    Create a user:

    We'll send a POST request to /api/v1/user, with the JSON body of our new user postman

    Let's get all users again: postman

    You can see that now a get request for all users produces an array of our one user.

  8. Authentication

    So we can get and post users, but what if we try to delete or update a user? Let's try it: postman

    So the server responded with 401 Unauthorized, and a body of "not self". What gives?

    Open at our user router (/src/routes/user.ts). Look at our DELETE route:

    router.delete(`/:id`, loader, onlySelf, deleteEndpoint);

    Notice onlySelf - that means only the user can access this route. We're not logged in yet, so we're certainly not "self"

    Create another router file, src/routes/auth.ts. This will be our authentication route so that we can log-in.

    // src/routes/auth.ts
    
    import { Router } from 'express';
    import { loginEndpoint, logoutEndpoint } from 'pointyapi/endpoints';
    import { ExampleUser } from 'pointyapi/models';
    import { setModel } from 'pointyapi';
    
    const router: Router = Router();
    
    // Set our route model & activate auth route
    async function loader(request, response, next) {
    	if (await setModel(request, response, ExampleUser, true)) {
    		next();
    	}
    }
    
    // Router endpoints
    router.post('/', loader, loginEndpoint);
    router.delete('/', loader, logoutEndpoint);
    
    export const authRouter: Router = router;

    And import this into our index.ts:

    ...
    // Routes
    // TODO: We will import routes here
    import { userRouter } from './routes/user';
    import { authRouter } from './routes/auth'; // Add import to our auth route
    
    ...
    
    // Routes
    // TODO: We will set our routes here
    app.use('/api/v1/user', userRouter);
    app.use('/api/v1/auth', authRouter); // Add our user route to the app
    
    // Database
    await pointy.db
    	.setEntities(
    		[
    			/* TODO: We will set our models here */
    			ExampleUser
    		]
    	)
    
    ...

    Restart the server (ctrl+c to stop the server)

    We can login with Postman: postman

    We received a "token" back (yours will be different):

    "token": "ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBaQ0k2TVN3aWFXRjBJam94TlRVeE1qa3dOREkxTENKbGVIQWlPakUxTlRFek1EUTRNalY5Lk40UWNJV1hPYWVzWW9KOWh4VGx1X182dnhNVndpbkFXNGhRY2JWNkRKSUE="

    We can now use that token to delete our user: postman

    Notice that now we get a 204 No Content (which means deleted successfully!).

    What about the refreshToken? You may notice you got two tokens back: token and refreshToken.

    The token is short-lived - it only lasts 15 minutes or so. The refreshToken is long-living - it lasts about a week.

    You can use the refreshToken to issue a new accessToken when it expires.

  9. Production

To launch in production mode, please make sure the following variables are set (environment variables/.env)

  • SITE_TITLE - Set the site title
  • ALLOW_ORIGIN - Set your client URL to add the client to the CORS policy
  • JWT_KEY - Set your token key to make JWT cryptographically secure
  • JWT_TTL - Set your token time-to-live (seconds). Default is 15 minutes
  • JWT_REFRESH_TTL - Set your refresh token time-to-live (seconds). Default is 7 days.

UUID vs Auto-Incremented IDs

It is a security risk to use auto-incremented IDs, and you should instead use UUID for all ID columns.

To switch to using UUIDs:

  • Install the pgcrypto extension
  • Tell TypeORM to use pgcrypto by placing the line uuidExtension: 'pgcrypto' in orm-cli.js
  • Change all PrimaryGeneratedColumn() to PrimaryGeneratedColumn('uuid')
  • Change all public id: number members to public id: string
  • Make sure you remove IsInt() from all ID fields, if it exists
  • Ensure your front-end and anywhere you may access the ID is expecting a string

Example:

	// User ID
	@PrimaryGeneratedColumn('uuid') // Primary column
	@BodyguardKey() // Authentication - User must match this to be considered "self"
	@AnyoneCanRead() // Authorization - Anyone is allowed to read this field
	public id: string = undefined;

Continued Reading

PointyAPI

Read more about PointyAPI by cloning the repository and building the docs:

git clone https://github.com/StatelessStudio/pointyapi
cd pointyapi
npm install --ignore-scripts
npm i -g typedoc
npm run docs

Now open docs/index.html in your web browser.

You can also check out the examples in test/examples and even check out the test specs in test/specs

TypeORM

Read more about TypeORM: https://github.com/typeorm/typeorm

Class Validator

Read more about Class Validator: https://github.com/typestack/class-validator

Express

Read more about Express: https://www.express.com/

Securing your Server

Read the security checklist: https://github.com/shieldfy/API-Security-Checklist