Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add typebox validator middleware #75

Merged
merged 1 commit into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/typebox-validator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# @lambda-middleware/typebox-validator

[![npm version](https://badge.fury.io/js/%40lambda-middleware%2Ftypebox-validator.svg)](https://npmjs.org/package/@lambda-middleware/typebox-validator)
[![downloads](https://img.shields.io/npm/dw/%40lambda-middleware%2Ftypebox-validator.svg)](https://npmjs.org/package/@lambda-middleware/typebox-validator)
[![open issues](https://img.shields.io/github/issues-raw/dbartholomae/lambda-middleware.svg)](https://github.com/dbartholomae/lambda-middleware/issues)
[![debug](https://img.shields.io/badge/debug-blue.svg)](https://github.com/visionmedia/debug#readme)
[![build status](https://github.com/dbartholomae/lambda-middleware/workflows/.github/workflows/build.yml/badge.svg?branch=main)](https://github.com/dbartholomae/lambda-middleware/actions?query=workflow%3A.github%2Fworkflows%2Fbuild.yml)
[![codecov](https://codecov.io/gh/dbartholomae/lambda-middleware/branch/main/graph/badge.svg)](https://codecov.io/gh/dbartholomae/lambda-middleware)
[![dependency status](https://david-dm.org/dbartholomae/lambda-middleware.svg?theme=shields.io)](https://david-dm.org/dbartholomae/lambda-middleware)
[![devDependency status](https://david-dm.org/dbartholomae/lambda-middleware/dev-status.svg)](https://david-dm.org/dbartholomae/lambda-middleware?type=dev)

A validation middleware for AWS http lambda functions based on [typebox](https://github.com/sinclairzx81/typebox).

## Lambda middleware

This middleware is part of the [lambda middleware series](https://dbartholomae.github.io/lambda-middleware/). It can be used independently.

## Usage

```typescript
import { composeHandler } from "@lambda-middleware/compose";
import { errorHandler } from "@lambda-middleware/http-error-handler";
import { Type } from "@sinclair/typebox";
import { typeboxValidator } from "@lambda-middleware/typebox-validator";
import { APIGatewayProxyResult } from "aws-lambda";

const NameBodySchema = Type.Object({
firstName: Type.String(),
lastName: Type.String(),
});

type NameBody = {
firstName: string;
lastName: string;
};

async function helloWorld(event: { body: NameBody }): Promise<APIGatewayProxyResult> {
return {
body: `Hello ${event.body.firstName} ${event.body.lastName}`,
headers: {
"content-type": "text",
},
statusCode: 200,
};
}

export const handler = composeHandler(
errorHandler(),
typeboxValidator(NameBodySchema),
helloWorld
);
```
33 changes: 33 additions & 0 deletions packages/typebox-validator/examples/helloWorld.int-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import request from "supertest";
import { handler } from "./helloWorld";

const server = request("http://localhost:3000/dev");

describe("Handler with typebox validator middleware", () => {
describe("with valid input", () => {
it("returns 200", async () => {
const response = await server
.post("/hello")
.send({
firstName: "John",
lastName: "Doe",
})
.expect(200);
expect(response.text).toEqual("Hello John Doe");
});
});

describe("with invalid input", () => {
it("returns 400 and the validation error", async () => {
const response = await server
.post("/hello")
.send({
firstName: "John",
})
.expect(400);
expect(JSON.stringify(response.body)).toContain(
"lastName must be a string"
);
});
});
});
31 changes: 31 additions & 0 deletions packages/typebox-validator/examples/helloWorld.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { composeHandler } from "@lambda-middleware/compose";
import { errorHandler } from "@lambda-middleware/http-error-handler";
import { Type } from "@sinclair/typebox";
import { typeboxValidator } from "../src/typeboxValidator";
import { APIGatewayProxyResult } from "aws-lambda";

const NameBodySchema = Type.Object({
firstName: Type.String(),
lastName: Type.String(),
});

type NameBody = {
firstName: string;
lastName: string;
};

async function helloWorld(event: { body: NameBody }): Promise<APIGatewayProxyResult> {
return {
body: `Hello ${event.body.firstName} ${event.body.lastName}`,
headers: {
"content-type": "text",
},
statusCode: 200,
};
}

export const handler = composeHandler(
errorHandler(),
typeboxValidator(NameBodySchema),
helloWorld
);
2 changes: 2 additions & 0 deletions packages/typebox-validator/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const baseConfig = require("../../jest.unit.config");
module.exports = baseConfig;
2 changes: 2 additions & 0 deletions packages/typebox-validator/jest.integration.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const baseConfig = require("../../jest.integration.config");
module.exports = baseConfig;
83 changes: 83 additions & 0 deletions packages/typebox-validator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
{
"name": "@lambda-middleware/typebox-validator",
"version": "1.0.0",
"description": "A validation middleware for AWS http lambda functions based on typebox.",
"homepage": "https://dbartholomae.github.io/lambda-middleware/",
"license": "MIT",
"author": {
"name": "Daniel Bartholomae",
"email": "[email protected]",
"url": ""
},
"files": [
"lib"
],
"main": "lib/index.js",
"keywords": [
"aws",
"lambda",
"middleware",
"validation",
"validator"
],
"types": "lib/index.d.ts",
"engines": {
"npm": ">= 4.0.0"
},
"private": false,
"dependencies": {
"@lambda-middleware/utils": "^1.0.4",
"debug": ">=4.1.0",
"@sinclair/typebox": "^0.21.0",
"tslib": "^2.0.1"
},
"directories": {
"example": "examples"
},
"scripts": {
"build": "rimraf ./lib && tsc --project tsconfig.build.json",
"lint": "eslint src/**/*.ts examples/**/*.ts",
"pretest": "npm run build",
"start": "cd test && serverless offline",
"test": "npm run lint && npm run test:unit && npm run test:integration && pkg-ok",
"test:integration": "concurrently --timeout 600000 --kill-others --success first \"cd test && serverless offline\" \"wait-on http://localhost:3000/dev/status && jest -c jest.integration.config.js\"",
"test:unit": "jest"
},
"devDependencies": {
"@lambda-middleware/compose": "^1.2.0",
"@lambda-middleware/http-error-handler": "^2.0.0",
"@types/debug": "^4.1.5",
"@types/jest": "^25.2.1",
"@types/supertest": "^2.0.8",
"@types/aws-lambda": "^8.10.47",
"@typescript-eslint/parser": "^2.26.0",
"@typescript-eslint/eslint-plugin": "^2.26.0",
"aws-lambda": "^1.0.5",
"concurrently": "^5.1.0",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.10.1",
"eslint-plugin-prettier": "^3.1.2",
"jest": "^25.2.7",
"jest-junit": "^10.0.0",
"pkg-ok": "^2.3.1",
"prettier": "^2.0.2",
"prettier-config-standard": "^1.0.1",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"serverless": "^2.57.0",
"serverless-offline": "^8.0.0",
"serverless-webpack": "^5.5.3",
"source-map-support": "^0.5.16",
"supertest": "^4.0.2",
"ts-jest": "^25.3.1",
"ts-loader": "^6.2.2",
"typescript": "^4.5.4",
"wait-on": "^5.2.0",
"webpack": "^4.41.5"
},
"repository": {
"type": "git",
"url": "[email protected]:dbartholomae/lambda-middleware.git",
"directory": "packages/typebox-validator"
}
}
119 changes: 119 additions & 0 deletions packages/typebox-validator/src/typeboxValidator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { createContext, createEvent } from "@lambda-middleware/utils";
import { APIGatewayEvent } from "aws-lambda";
import { Type, Static } from "@sinclair/typebox";
import { typeboxValidator } from "./typeboxValidator";

const NameBodySchema = Type.Object({
firstName: Type.String(),
lastName: Type.String(),
});

type NameBody = Static<typeof NameBodySchema>;

describe("typeboxValidator", () => {
describe("with valid input", () => {
const body = JSON.stringify({
firstName: "John",
lastName: "Doe",
});

it("sets the body to the validated value", async () => {
const handler = jest.fn();
await typeboxValidator(NameBodySchema)(handler)(
createEvent({ body }),
createContext()
);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
body: {
firstName: "John",
lastName: "Doe",
},
}),
expect.anything()
);
});
});

describe("with superfluous input", () => {
const body = JSON.stringify({
firstName: "John",
lastName: "Doe",
injection: "malicious",
});

it("omits the superfluous input", async () => {
const handler = jest.fn();
await typeboxValidator(NameBodySchema)(handler)(
createEvent({ body }),
createContext()
);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
body: {
firstName: "John",
lastName: "Doe",
},
}),
expect.anything()
);
});
});

describe("with invalid input", () => {
const body = JSON.stringify({
firstName: "John",
});

it("throws an error with statusCode 400", async () => {
const handler = jest.fn();
await expect(
typeboxValidator(NameBodySchema)(handler)(
createEvent({ body }),
createContext()
)
).rejects.toMatchObject({
statusCode: 400,
});
});
});

describe("with null input", () => {
const body = null;

it("throws an error with statusCode 400", async () => {
const handler = jest.fn();
await expect(
typeboxValidator(NameBodySchema)(handler)(
createEvent({ body }),
createContext()
)
).rejects.toMatchObject({
statusCode: 400,
});
});
});

describe("with an empty body and optional validation", () => {
const body = "";

const OptionalNameBodySchema = Type.Object({
firstName: Type.Optional(Type.String()),
lastName: Type.Optional(Type.String()),
});

type OptionalNameBody = Static<typeof OptionalNameBodySchema>;

it("returns the handler's response", async () => {
const expectedResponse = {
statusCode: 200,
body: "Done",
};
const handler = jest.fn().mockResolvedValue(expectedResponse);
const actualResponse = await typeboxValidator(OptionalNameBodySchema)(
handler
)(createEvent({ body }), createContext());
expect(actualResponse).toEqual(expectedResponse);
});
});
});
28 changes: 28 additions & 0 deletions packages/typebox-validator/src/typeboxValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { PromiseHandler } from "@lambda-middleware/utils";
import debugFactory, { IDebugger } from "debug";
import { APIGatewayEvent, Context } from "aws-lambda";
import { Type, TypeCompiler } from "@sinclair/typebox";

const logger: IDebugger = debugFactory("@lambda-middleware/typebox-validator");

export type WithBody<Event, Body> = Omit<Event, "body"> & { body: Body };

export const typeboxValidator = <T extends object>(schema: Type<T>) => <R>(
handler: PromiseHandler<WithBody<APIGatewayEvent, T>, R>
) => async (event: APIGatewayEvent, context: Context): Promise<R> => {
logger(`Checking input ${JSON.stringify(event.body)}`);
try {
const body = event.body ?? "{}";
const compiledSchema = TypeCompiler.Compile(schema);
const validationResult = compiledSchema.Check(JSON.parse(body));
if (!validationResult) {
throw new Error("Validation failed");
}
logger("Input is valid");
return handler({ ...event, body: JSON.parse(body) }, context);
} catch (error) {
logger("Input is invalid");
(error as { statusCode?: number }).statusCode = 400;
throw error;
}
};
2 changes: 2 additions & 0 deletions packages/typebox-validator/test/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
lib
8 changes: 8 additions & 0 deletions packages/typebox-validator/test/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { handler as fullExample } from '../examples/helloWorld';

export async function status() {
return {
body: '',
statusCode: 200
};
}
24 changes: 24 additions & 0 deletions packages/typebox-validator/test/serverless.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
service: test-microservice

plugins:
- serverless-webpack
- serverless-offline

provider:
name: aws
runtime: nodejs14.x

functions:
hello:
handler: handler.fullExample
events:
- http:
method: post
path: hello

status:
handler: handler.status
events:
- http:
method: get
path: status
1 change: 1 addition & 0 deletions packages/typebox-validator/test/source-map-install.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require("source-map-support").install();
Loading
Loading