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 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
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
6 changes: 5 additions & 1 deletion generators/app/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const Generator = require('yeoman-generator');
const Generator = require('yeoman-generator')
const kebabCase = require('kebab-case')

module.exports = class extends Generator {
async prompting() {
Expand All @@ -21,6 +22,8 @@ module.exports = class extends Generator {
default: 'VPC'
}
])

this.answers.title = kebabCase(this.answers.title)
}

writing() {
Expand Down Expand Up @@ -52,6 +55,7 @@ module.exports = class extends Generator {

// Save project title to reuse it in other generators
this.config.set('projectName', this.answers.title)
this.config.set('models', ["Game"])
}


Expand Down
1 change: 1 addition & 0 deletions generators/app/templates/.gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
build
.build
.serverless
node_modules
.idea
.DS_Store
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 '
Dizzzmas marked this conversation as resolved.
Show resolved Hide resolved
\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
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
2 changes: 2 additions & 0 deletions generators/app/templates/packages/backend/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
type PaginatedResultPromise<T> = Promise<import("aws-lambda").APIGatewayProxyResultV2<import("demo-core").PaginatedResponse<T>>>
Dizzzmas marked this conversation as resolved.
Show resolved Hide resolved
type ResultPromise<T> = Promise<import("aws-lambda").APIGatewayProxyResultV2<T>>
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
4 changes: 2 additions & 2 deletions generators/app/templates/packages/backend/serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ plugins:
- serverless-plugin-optimize

functions:
- ${file(cloudformation/serverlessFunctions/app.yml)} # API Gateway Lambdas
- ${file(cloudformation/serverlessFunctions/db.yml)} # DB Lambdas (migrations, seeding, etc.)
- ${file(cloudformation/serverlessFunctions/app/game.yml)}
- ${file(cloudformation/serverlessFunctions/db.yml)}

resources:
- ${file(cloudformation/vpc.yml)}
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
131 changes: 31 additions & 100 deletions generators/app/templates/packages/backend/src/api/game/crud.ts
Original file line number Diff line number Diff line change
@@ -1,114 +1,45 @@
import { Game, PaginatedResponse } from "<%= title %>-core"
import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"
import { db } from "../../db"
import { getPagesData, getPaginationData } from "../../util/pagination"
import { Game } from "<%= title %>-core"
import { GameSchemaLite } from "<%= title %>-core"
import { getPageParams } from "../../util/pagination"
import createHttpError from "http-errors"
import { applyErrorHandlingAndValidation } from "../../util/serialization"
import { APIGatewayProxyEventV2, APIGatewayProxyEventPathParameters } from "aws-lambda"
import { listGames, createGame, getGameById, updateGameById, deleteGameById } from "../../domain/game"


interface ICreateGameRequest {
name: string
}
const create = async (event: { body: GameSchemaLite }): ResultPromise<Game> =>
await createGame(event.body)

interface IUpdateGameRequest {
name: string
const list = async (event: APIGatewayProxyEventV2): PaginatedResultPromise<Game> => {
const pageParams = getPageParams(event.queryStringParameters)
return await listGames(pageParams)
}

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

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)

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
return await getGameById(event.pathParameters["gameId"])
}

export async function list(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2<PaginatedResponse<Game>>> {
/**
* Get a paginated list
*/
const pagesData = getPagesData(event.queryStringParameters)
const updateById = async (event: { body: GameSchemaLite, pathParameters: APIGatewayProxyEventPathParameters }): ResultPromise<Game> => {
if (!event.pathParameters || !event.pathParameters["gameId"])
throw createHttpError(404, "No path parameters found or gameId not present in them")

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),
}
const gameId = event.pathParameters["gameId"]
return await updateGameById(gameId, event.body)
}

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 deleteById = async (event: { pathParameters: APIGatewayProxyEventPathParameters }): ResultPromise<void> => {
if (!event.pathParameters || !event.pathParameters["gameId"])
throw createHttpError(404, "No path parameters found or gameId not present in them")

const repo = conn.getRepository(Game)

const game = await repo.findOneOrFail(gameId)

return game
const gameId = event.pathParameters["gameId"]
await deleteGameById(gameId)
}


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 createHandler = applyErrorHandlingAndValidation<Game>(GameSchemaLite, create)
export const listHandler = applyErrorHandlingAndValidation<Game[]>(Game, list)
export const getByIdHandler = applyErrorHandlingAndValidation<Game>(Game, getById)
export const updateByIdHandler = applyErrorHandlingAndValidation<Game>(GameSchemaLite, updateById)
export const deleteByIdHandler = applyErrorHandlingAndValidation<Game>(Game, deleteById)