-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
fa43b97
commit f31b35c
Showing
8 changed files
with
10,605 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
import { APIGatewayProxyResultV2 } from 'aws-lambda'; | ||
|
||
export class ApiError extends Error { | ||
statusCode: number; | ||
publicMessage: string; | ||
privateMessage?: string; | ||
cause: any; | ||
|
||
constructor({ | ||
statusCode, | ||
publicMessage, | ||
privateMessage, | ||
cause, | ||
}: { | ||
statusCode: number; | ||
publicMessage?: string; | ||
privateMessage?: string; | ||
cause?: any; | ||
}) { | ||
super(); | ||
this.statusCode = statusCode; | ||
this.publicMessage = publicMessage || 'Temporary server error'; | ||
this.privateMessage = privateMessage; | ||
this.cause = cause; | ||
} | ||
|
||
apiGatewayResultV2(): APIGatewayProxyResultV2 { | ||
console.error( | ||
'Status Code:%d\rPublic Message: %s\rPrivate Message:%s\rCause:%s\rStack:%s\r', | ||
this.statusCode, | ||
this.publicMessage, | ||
this.privateMessage, | ||
this.cause, | ||
this.stack, | ||
); | ||
return { | ||
statusCode: this.statusCode, | ||
isBase64Encoded: false, | ||
headers: { | ||
'Content-Type': 'application/json', | ||
'Access-Control-Allow-Origin': '*', | ||
}, | ||
body: JSON.stringify({ message: this.publicMessage, code: this.statusCode }), | ||
}; | ||
} | ||
} | ||
|
||
function unknownError(err: any): APIGatewayProxyResultV2 { | ||
console.error('Unknown error: ', err); | ||
return { | ||
statusCode: 500, | ||
isBase64Encoded: false, | ||
headers: { | ||
'Content-Type': 'application/json', | ||
'Access-Control-Allow-Origin': '*', | ||
}, | ||
body: JSON.stringify({ message: 'Temporary server error', code: 500 }), | ||
}; | ||
} | ||
|
||
export function errToApiGatewayProxyResultV2(err: any): APIGatewayProxyResultV2 { | ||
if (err instanceof ApiError) { | ||
return err.apiGatewayResultV2(); | ||
} | ||
return unknownError(err); | ||
} | ||
|
||
/** | ||
* Returns a successful API response, with the given value marshalled as | ||
* the JSON body. | ||
* @param value The JSON body to return. | ||
* @returns The API gateway result object. | ||
*/ | ||
export function success(value: any): APIGatewayProxyResultV2 { | ||
console.log('Response: %j', value); | ||
return { | ||
statusCode: 200, | ||
body: JSON.stringify(value), | ||
}; | ||
} | ||
|
||
export interface UserInfo { | ||
username: string; | ||
email: string; | ||
} | ||
|
||
/** | ||
* Extracts the user info from the Lambda event. | ||
* @param event The Lambda event to get the user info from. | ||
* @returns An object containing the username and email, if present on the event. | ||
*/ | ||
export function getUserInfo(event: any): UserInfo { | ||
const claims = event.requestContext?.authorizer?.jwt?.claims; | ||
if (!claims) { | ||
return { | ||
username: '', | ||
email: '', | ||
}; | ||
} | ||
|
||
return { | ||
username: claims['cognito:username'] || '', | ||
email: claims['email'] || '', | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,249 @@ | ||
'use strict'; | ||
|
||
import { | ||
ConditionalCheckFailedException, | ||
DynamoDBClient, | ||
GetItemCommand, | ||
PutItemCommand, | ||
UpdateItemCommand, | ||
} from '@aws-sdk/client-dynamodb'; | ||
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'; | ||
import { | ||
Directory, | ||
DirectoryItem, | ||
DirectoryItemType, | ||
DirectorySchema, | ||
DirectoryVisibility, | ||
} from '@jackstenglein/chess-dojo-common/src/database/directory'; | ||
import { APIGatewayProxyEventV2, APIGatewayProxyHandlerV2 } from 'aws-lambda'; | ||
import { z } from 'zod'; | ||
import { ApiError, errToApiGatewayProxyResultV2, getUserInfo, success } from './api'; | ||
|
||
const dynamo = new DynamoDBClient({ region: 'us-east-1' }); | ||
const directoryTable = process.env.stage + '-directories'; | ||
|
||
const createDirectorySchema = DirectorySchema.pick({ | ||
visibility: true, | ||
}).merge( | ||
z.object({ | ||
/** The full path of the directory containing the new directory. */ | ||
parent: z | ||
.string() | ||
.trim() | ||
.regex(/^[ ./a-zA-Z0-9_-]+$/) | ||
.refine((val) => !val.includes('//')), | ||
|
||
/** The name of the directory to create. Must be a single component and therefore cannot contain / */ | ||
name: z | ||
.string() | ||
.trim() | ||
.regex(/^[ .a-zA-Z0-9_-]+$/), | ||
}), | ||
); | ||
|
||
type createDirectoryRequest = z.infer<typeof createDirectorySchema>; | ||
|
||
export const handler: APIGatewayProxyHandlerV2 = async (event) => { | ||
try { | ||
console.log('Event: %j', event); | ||
const result = await handleCreateDirectory(event); | ||
return success(result); | ||
} catch (err) { | ||
return errToApiGatewayProxyResultV2(err); | ||
} | ||
}; | ||
|
||
async function handleCreateDirectory(event: APIGatewayProxyEventV2) { | ||
const userInfo = getUserInfo(event); | ||
if (!userInfo.username) { | ||
throw new ApiError({ | ||
statusCode: 400, | ||
publicMessage: 'Invalid request: username is required', | ||
}); | ||
} | ||
|
||
const request = getRequest(event); | ||
|
||
let parent = await fetchDirectory(userInfo.username, request.parent); | ||
if (parent?.items[request.name]) { | ||
throw new ApiError({ | ||
statusCode: 400, | ||
publicMessage: `${parent.name}/${request.name} already exists`, | ||
}); | ||
} | ||
|
||
if (!parent && request.parent === '/') { | ||
parent = await createRootDirectory(userInfo.username, request); | ||
} else if (parent) { | ||
parent = await addSubDirectory(parent, request); | ||
} else { | ||
throw new ApiError({ | ||
statusCode: 400, | ||
publicMessage: `${request.parent} does not exist or you do not own it`, | ||
}); | ||
} | ||
|
||
if (parent.name.endsWith('/')) { | ||
parent.name = parent.name.slice(0, parent.name.length - 1); | ||
} | ||
|
||
const child: Directory = { | ||
owner: userInfo.username, | ||
name: `${parent.name}/${request.name}`, | ||
visibility: request.visibility, | ||
items: {}, | ||
createdAt: parent.items[request.name].metadata.createdAt, | ||
updatedAt: parent.items[request.name].metadata.createdAt, | ||
}; | ||
await createDirectory(child); | ||
|
||
return { | ||
directory: child, | ||
directoryItem: parent.items[request.name], | ||
}; | ||
} | ||
|
||
function getRequest(event: APIGatewayProxyEventV2): createDirectoryRequest { | ||
try { | ||
const body = JSON.parse(event.body || '{}'); | ||
return createDirectorySchema.parse(body); | ||
} catch (err) { | ||
console.error('Failed to unmarshall body: ', err); | ||
throw new ApiError({ | ||
statusCode: 400, | ||
publicMessage: 'Invalid request: body could not be unmarshaled', | ||
cause: err, | ||
}); | ||
} | ||
} | ||
|
||
/** | ||
* Fetches the directory with the given owner and name from DynamoDB. | ||
* @param owner The owner of the directory. | ||
* @param name The full path name of the directory. | ||
* @returns The given directory, or undefined if it does not exist. | ||
*/ | ||
async function fetchDirectory( | ||
owner: string, | ||
name: string, | ||
): Promise<Directory | undefined> { | ||
const getItemOutput = await dynamo.send( | ||
new GetItemCommand({ | ||
Key: { | ||
owner: { S: owner }, | ||
name: { S: name }, | ||
}, | ||
TableName: directoryTable, | ||
}), | ||
); | ||
if (!getItemOutput.Item) { | ||
return undefined; | ||
} | ||
|
||
const directory = unmarshall(getItemOutput.Item); | ||
return DirectorySchema.parse(directory); | ||
} | ||
|
||
/** | ||
* Creates the root directory for the given user and request. | ||
* @param owner The username to create the root directory for. | ||
* @param request The create directory request that caused the root directory to be created. | ||
* @returns The created directory. | ||
*/ | ||
async function createRootDirectory( | ||
owner: string, | ||
request: createDirectoryRequest, | ||
): Promise<Directory> { | ||
const createdAt = new Date().toISOString(); | ||
const directory = { | ||
owner, | ||
name: '/', | ||
visibility: DirectoryVisibility.PUBLIC, | ||
createdAt, | ||
updatedAt: createdAt, | ||
items: { | ||
[request.name]: { | ||
type: DirectoryItemType.DIRECTORY, | ||
id: request.name, | ||
metadata: { | ||
createdAt, | ||
updatedAt: createdAt, | ||
visibility: request.visibility, | ||
}, | ||
}, | ||
}, | ||
}; | ||
await createDirectory(directory); | ||
return directory; | ||
} | ||
|
||
/** | ||
* Puts the given directory in DynamoDB, if it does not already exist. | ||
* @param directory The directory to create. | ||
*/ | ||
async function createDirectory(directory: Directory) { | ||
try { | ||
await dynamo.send( | ||
new PutItemCommand({ | ||
Item: marshall(directory), | ||
ConditionExpression: 'attribute_not_exists(owner)', | ||
TableName: directoryTable, | ||
}), | ||
); | ||
} catch (err) { | ||
if (err instanceof ConditionalCheckFailedException) { | ||
throw new ApiError({ | ||
statusCode: 400, | ||
publicMessage: `${directory.name} already exists`, | ||
cause: err, | ||
}); | ||
} | ||
throw new ApiError({ statusCode: 500, cause: err }); | ||
} | ||
} | ||
|
||
/** | ||
* Adds a subdirectory as an item to the given parent directory. | ||
* @param parent The parent directory to add the subdirectory to. | ||
* @param request The request to create the subdirectory. | ||
* @returns The updated parent directory. | ||
*/ | ||
async function addSubDirectory( | ||
parent: Directory, | ||
request: createDirectoryRequest, | ||
): Promise<Directory> { | ||
const createdAt = new Date().toISOString(); | ||
|
||
const subdirectory: DirectoryItem = { | ||
type: DirectoryItemType.DIRECTORY, | ||
id: request.name, | ||
metadata: { | ||
createdAt, | ||
updatedAt: createdAt, | ||
visibility: request.visibility, | ||
}, | ||
}; | ||
|
||
const input = new UpdateItemCommand({ | ||
Key: { | ||
owner: { S: parent.owner }, | ||
name: { S: parent.name }, | ||
}, | ||
ConditionExpression: 'attribute_not_exists(#items.#name)', | ||
UpdateExpression: `SET #items.#name = :directory, #updatedAt = :updatedAt`, | ||
ExpressionAttributeNames: { | ||
'#items': 'items', | ||
'#name': request.name, | ||
}, | ||
ExpressionAttributeValues: { | ||
':directory': { M: marshall(subdirectory) }, | ||
':updatedAt': { S: createdAt }, | ||
}, | ||
TableName: directoryTable, | ||
ReturnValues: 'NONE', | ||
}); | ||
await dynamo.send(input); | ||
|
||
parent.items[request.name] = subdirectory; | ||
return parent; | ||
} |
Oops, something went wrong.