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 API request validation #3

Merged
merged 7 commits into from
Jan 13, 2021
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules/
.DS_Store
.env.local
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ Resources:
# authentication: S3AccessCreds
/home/ec2-user/.psqlrc:
content: |
\set PROMPT1 '%[%033[1;31m%]%M%[%033[0m%]:%> %[%033[1;33m%]%n%[%033[0m%]@%/%R%#%x '
\set PROMPT1 '%[%033[1;31m%]%M%[%033[0m%]: %[%033[1;33m%]%n%[%033[0m%]@%/%R%#%x '
\pset pager off
\set COMP_KEYWORD_CASE upper
\set VERBOSITY verbose
Expand Down
Original file line number Diff line number Diff line change
@@ -1,44 +1,39 @@
listGames:
handler: src/api/game/crud.list
handler: src/api/game/crud.listHandler
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case you wonder what those Game models and API endpoints are, they're just examples the project is gonna get generated with.

I think it's gonna turn out helpful for whoever's using our template.

vpc: ${self:custom.vpc}
events:
- httpApi:
method: GET
path: /game
integration: lambda

createGame:
handler: src/api/game/crud.create
handler: src/api/game/crud.createHandler
vpc: ${self:custom.vpc}
events:
- httpApi:
method: POST
path: /game
integration: lambda

getGameById:
handler: src/api/game/crud.getById
handler: src/api/game/crud.getByIdHandler
vpc: ${self:custom.vpc}
events:
- httpApi:
method: GET
path: /game/{gameId}
integration: lambda

updateGameById:
handler: src/api/game/crud.updateById
handler: src/api/game/crud.updateByIdHandler
vpc: ${self:custom.vpc}
events:
- httpApi:
method: PATCH
path: /game/{gameId}
integration: lambda

deleteGameById:
handler: src/api/game/crud.delete
handler: src/api/game/crud.deleteByIdHandler
vpc: ${self:custom.vpc}
events:
- httpApi:
method: DELETE
path: /game/{gameId}
integration: lambda
13 changes: 7 additions & 6 deletions generators/app/templates/packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"license": "ISC",
"dependencies": {
"@lambda-middleware/class-validator": "^1.0.1",
"@lambda-middleware/compose": "^1.0.1",
"@lambda-middleware/compose": "^1.2.0",
"@lambda-middleware/http-error-handler": "^1.0.1",
"aws-lambda": "^1.0.6",
"aws-sdk": "^2.747.0",
Expand All @@ -36,22 +36,23 @@
"convict": "^6.0.0",
"dotenv": "^8.2.0",
"dotenv-flow": "^3.2.0",
"http-errors": "^1.8.0",
"pg": "^7.3.0",
"reflect-metadata": "^0.1.10",
"serverless": "^2.3.0",
"reflect-metadata": "^0.1.13",
"serverless": "2.17.0",
"tslib": "^1.11.2"
},
"devDependencies": {
"@babel/helper-validator-option": "^7.12.0",
"@types/aws-lambda": "^8.10.62",
"@types/convict": "^5.2.2",
"@types/dotenv-flow": "^3.1.0",
"@types/http-errors": "^1.8.0",
"@types/jest": "^26.0.18",
"@types/node": "^8.0.29",
"jest": "24.9.0",
"serverless-cognito-add-custom-attributes": "^0.3.0",
"serverless-dotenv-plugin": "^3.0.0",
"serverless-layers": "^2.3.0",
"serverless-dotenv-plugin": "^3.1.0",
"serverless-layers": "^2.3.3",
"serverless-offline": "^6.8.0",
"serverless-plugin-optimize": "^4.1.4-rc.1",
"serverless-plugin-typescript": "^1.1.9",
Expand Down
31 changes: 16 additions & 15 deletions generators/app/templates/packages/backend/src/api/game/crud.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { db } from "../../db"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When trying to use absolute paths. Deployed code was failing with unable to find module error.

Hence backend uses relative paths now

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import { Connection } from 'typeorm';
import { Game, PaginatedResponse, gameFactory } from '<%= title %>-core'
import { APIGatewayProxyEventV2 } from 'aws-lambda'
import { list } from './crud'
import { getById } from './crud'
import { create } from './crud'
import { updateById } from './crud'
import { deleteById } from './crud'
import { Game, PaginatedResponse, gameFactory, GameSchemaLite } from '<%= title %>-core'
import { APIGatewayProxyEventV2, APIGatewayProxyEventPathParameters } from 'aws-lambda'
import { listHandler, getByIdHandler, createHandler, updateByIdHandler, deleteByIdHandler } from './crud'


let conn: Connection
Expand Down Expand Up @@ -34,24 +30,29 @@ describe('Test entity API', () => {

await repo.save(entity)

const entities: Game[] = (await list({} as APIGatewayProxyEventV2) as PaginatedResponse<Game>).items
const entities: Game[] = (await listHandler({} as APIGatewayProxyEventV2) as PaginatedResponse<Game>).items

const entityIds: string[] = entities.map(e => e.id)
expect(entityIds).toContain(entity.id)
}
)

it(
"Test creating an entity.",
"Test creating an entity. Also test that the validator for request body works.",
async () => {
const entity: Game = await create({ body: JSON.stringify(gameFactory.build()) } as unknown as APIGatewayProxyEventV2) as Game
await createHandler({ body: JSON.stringify({ name: 1234 }) } as unknown as APIGatewayProxyEventV2) as Game

const repo = conn.getRepository(Game)

const count: number = await repo.createQueryBuilder("game").getCount()
let count: number = await repo.createQueryBuilder("game").getCount()

expect(count).toEqual(1)
expect(count).toEqual(0)

await createHandler({ body: JSON.stringify(gameFactory.build()) } as unknown as APIGatewayProxyEventV2) as Game

count = await repo.createQueryBuilder("game").getCount()

expect(count).toEqual(1)
}
)

Expand All @@ -67,7 +68,7 @@ describe('Test entity API', () => {
// Save doesn't return id when `create` with relationships is used :(
entityToGet = await repo.createQueryBuilder("game").getOneOrFail()

const receivedEntity: Game = await getById({ pathParameters: { gameId: entityToGet.id } } as unknown as APIGatewayProxyEventV2) as Game
const receivedEntity: Game = await getByIdHandler({ pathParameters: { gameId: entityToGet.id } } as unknown as APIGatewayProxyEventV2) as Game

expect(receivedEntity.id).toEqual(entityToGet.id)
}
Expand All @@ -89,7 +90,7 @@ describe('Test entity API', () => {
entityToUpdate = await repo.createQueryBuilder("game").getOneOrFail()

expect(entityToUpdate.name).not.toEqual(updatedEntity.name)
await updateById({ pathParameters: { gameId: entityToUpdate.id }, body: JSON.stringify(updatedEntity) } as unknown as APIGatewayProxyEventV2) as Game
await updateByIdHandler({ pathParameters: { gameId: entityToUpdate.id }, body: JSON.stringify(updatedEntity) } as unknown as APIGatewayProxyEventV2) as Game
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type casting with as is kinda ugly here, but had to use it because of type signatures for lambdas looking like this:

(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2<Game>>

When testing we don't need and don't have things featured on APIGatewayProxyEventV2 and APIGatewayProxyResultV2 interfaces.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can updateByIdHandler have a return type of Game?
Should we have a helper that builds a fake APIGatewayProxyEventV2 object so we don't need to type cast?


expect((await repo.findOneOrFail(entityToUpdate.id)).name).toEqual(updatedEntity.name)
}
Expand All @@ -109,7 +110,7 @@ describe('Test entity API', () => {

expect(await repo.createQueryBuilder("game").getCount()).toEqual(1)

await deleteById({ pathParameters: { gameId: entityToDelete.id } } as unknown as APIGatewayProxyEventV2) as Game
await deleteByIdHandler({ pathParameters: { gameId: entityToDelete.id } } as unknown as APIGatewayProxyEventV2) as Game

expect(await repo.createQueryBuilder("game").getCount()).toEqual(0)
}
Expand Down
115 changes: 8 additions & 107 deletions generators/app/templates/packages/backend/src/api/game/crud.ts
Original file line number Diff line number Diff line change
@@ -1,114 +1,15 @@
import { Game, PaginatedResponse } from "<%= title %>-core"
import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"
import { db } from "../../db"
import { getPagesData, getPaginationData } from "../../util/pagination"
import { GameSchemaLite } from "<%= title %>-core"
import { applyErrorHandlingAndValidation } from "../../util/serialization";
import { getById, create, updateById, deleteById, list } from "../../domain/game";


interface ICreateGameRequest {
name: string
}
export const createHandler = applyErrorHandlingAndValidation<Game>(GameSchemaLite, create)

interface IUpdateGameRequest {
name: string
}
export const listHandler = applyErrorHandlingAndValidation<Game[]>(Game, list)

export const getByIdHandler = applyErrorHandlingAndValidation<Game>(Game, getById)

export async function create(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2<Game>> {
if (!event.body) {
console.warn("No request body provided")
return {
statusCode: 400,
}
}
const body: ICreateGameRequest = JSON.parse(event.body)
export const updateByIdHandler = applyErrorHandlingAndValidation<Game>(GameSchemaLite, updateById)

const name = body.name

const conn = await db.getConnection()

const repo = conn.getRepository(Game)

const game = repo.create({
name: name,
})

await repo.save(game)


return game
}

export async function list(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2<PaginatedResponse<Game>>> {
/**
* Get a paginated list
*/
const pagesData = getPagesData(event.queryStringParameters)

const conn = await db.getConnection()
const games = await conn.getRepository(Game).createQueryBuilder("game").getMany()

const totalCount = await conn.getRepository(Game).createQueryBuilder("game").getCount()

return {
items: games,
paginationData: getPaginationData(totalCount, pagesData),
}
}

export async function getById(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2<Game>> {
if (!event.pathParameters || !event.pathParameters["gameId"]) {
console.warn("No path parameters found or gameId not present in them")
return { statusCode: 400 }
}

const gameId: string = event.pathParameters["gameId"]

const conn = await db.getConnection()

const repo = conn.getRepository(Game)

const game = await repo.findOneOrFail(gameId)

return game
}


export async function updateById(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2<Game>> {
if (!event.pathParameters || !event.pathParameters["gameId"] || !event.body) {
console.warn("No path parameters found or gameId not present in them")
return { statusCode: 400 }
}

const gameId: string = event.pathParameters["gameId"]

const body: IUpdateGameRequest = JSON.parse(event.body)


const conn = await db.getConnection()

const repo = conn.getRepository(Game)

const game = await repo.findOneOrFail(gameId)

return await repo.save({
...game,
...body
})
}

export async function deleteById(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2<void>> {
if (!event.pathParameters || !event.pathParameters["gameId"]) {
console.warn("No path parameters found or gameId not present in them")
return { statusCode: 400 }
}

const gameId: string = event.pathParameters["gameId"]

const conn = await db.getConnection()

const repo = conn.getRepository(Game)

const game = await repo.findOneOrFail(gameId)

await repo.remove(game)
}
export const deleteByIdHandler = applyErrorHandlingAndValidation<Game>(Game, deleteById)
90 changes: 90 additions & 0 deletions generators/app/templates/packages/backend/src/domain/game.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { GameSchemaLite, Game, PaginatedResponse } from "<%= title %>-core"
import { APIGatewayProxyResultV2, APIGatewayProxyEventV2, APIGatewayProxyEventPathParameters } from "aws-lambda"
import { db } from "../db"
import { getPagesData, getPaginationData } from "../util/pagination"
import createHttpError from "http-errors"
import { findByIdOr404 } from "../util/query"


export const create = async (event: { body: GameSchemaLite }): Promise<APIGatewayProxyResultV2<Game>> => {

const name = event.body.name

const conn = await db.getConnection()

const repo = conn.getRepository(Game)

const game = repo.create({
name: name,
})

await repo.save(game)


return game
}

export const list = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2<PaginatedResponse<Game>>> => {
/**
* Get a paginated list
*/
const pagesData = getPagesData(event.queryStringParameters)
const conn = await db.getConnection()
const games = await conn.getRepository(Game).createQueryBuilder("game").getMany()

const totalCount = await conn.getRepository(Game).createQueryBuilder("game").getCount()

return {
items: games,
paginationData: getPaginationData(totalCount, pagesData),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like putting pagination metadata in headers
Don't name identifiers with "data"

}
}


export const getById = async (event: { pathParameters: APIGatewayProxyEventPathParameters }): Promise<APIGatewayProxyResultV2<Game>> => {
if (!event.pathParameters || !event.pathParameters["gameId"])
throw createHttpError(404, "No path parameters found or gameId not present in them")

const gameId: string = event.pathParameters["gameId"]

const conn = await db.getConnection()

const repo = conn.getRepository(Game)

const game = await findByIdOr404(repo, gameId)

return game
}

export const updateById = async (event: { body: GameSchemaLite, pathParameters: APIGatewayProxyEventPathParameters }): Promise<APIGatewayProxyResultV2<Game>> => {
if (!event.pathParameters || !event.pathParameters["gameId"])
throw createHttpError(404, "No path parameters found or gameId not present in them")

const gameId = event.pathParameters["gameId"]

const conn = await db.getConnection()

const repo = conn.getRepository(Game)

const game = await findByIdOr404(repo, gameId)

return await repo.save({
...game,
...event.body
})
}

export const deleteById = async (event: { pathParameters: APIGatewayProxyEventPathParameters }): Promise<APIGatewayProxyResultV2<void>> => {
if (!event.pathParameters || !event.pathParameters["gameId"])
throw createHttpError(404, "No path parameters found or gameId not present in them")

const gameId = event.pathParameters["gameId"]

const conn = await db.getConnection()

const repo = conn.getRepository(Game)

const game = await findByIdOr404(repo, gameId)

await repo.remove(game)
}
Loading