Skip to content

Commit

Permalink
WIP: add create directory endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
jackstenglein committed Jul 24, 2024
1 parent fa43b97 commit f31b35c
Show file tree
Hide file tree
Showing 8 changed files with 10,605 additions and 10 deletions.
105 changes: 105 additions & 0 deletions backend/directoryService/api.ts
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'] || '',
};
}
249 changes: 249 additions & 0 deletions backend/directoryService/create.ts
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;
}
Loading

0 comments on commit f31b35c

Please sign in to comment.