UNPKG

dynamodb-admin-vperi-fork

Version:

GUI for DynamoDB. Useful for local development.

661 lines (588 loc) 21.7 kB
const express = require('express') const path = require('path') const errorhandler = require('errorhandler') const { extractKey, extractKeysForItems, parseKey, doSearch } = require('./util') const { purgeTable } = require('./actions/purgeTable') const asyncMiddleware = require('./utils/asyncMiddleware') const bodyParser = require('body-parser') const pickBy = require('lodash.pickby') const clc = require('cli-color') const cookieParser = require('cookie-parser') const DEFAULT_THEME = process.env.DEFAULT_THEME || 'light' const { DynamoDBClient, ListTablesCommand } = require('@aws-sdk/client-dynamodb') const { CreateTableCommand, DescribeTableCommand } = require('@aws-sdk/client-dynamodb') const { DeleteTableCommand, ScanCommand } = require('@aws-sdk/client-dynamodb') const { DynamoDBDocumentClient, GetCommand } = require('@aws-sdk/lib-dynamodb') const { PutCommand, DeleteCommand } = require('@aws-sdk/lib-dynamodb') function loadDynamoEndpoint(env, dynamoConfig) { if (typeof env.DYNAMO_ENDPOINT === 'string') { if (env.DYNAMO_ENDPOINT.indexOf('.amazonaws.com') > -1) { console.error( clc.red('dynamodb-admin is only intended for local development') ) process.exit(1) } dynamoConfig.endpoint = env.DYNAMO_ENDPOINT dynamoConfig.sslEnabled = env.DYNAMO_ENDPOINT.indexOf('https://') === 0 } else { console.log( clc.yellow( ' DYNAMO_ENDPOINT is not defined (using default of http://localhost:8000)' ) ) } } /** * Create the configuration for the local dynamodb instance. * * Region and AccessKeyId are determined as follows: * 1) Look at local aws configuration in ~/.aws/credentials * 2) Look at env variables env.AWS_REGION and env.AWS_ACCESS_KEY_ID * 3) Use default values 'us-east-1' and 'key' * * @param env - the process environment * @param AWS - the AWS SDK object * @returns {{endpoint: string, sslEnabled: boolean, region: string, accessKeyId: string}} */ function loadDynamoConfig(env) { const dynamoConfig = { endpoint: 'http://localhost:8000', sslEnabled: false, region: 'us-east-1' } loadDynamoEndpoint(env, dynamoConfig) return dynamoConfig } const createAwsConfig = () => { const env = process.env const dynamoConfig = loadDynamoConfig(env) console.log(clc.blackBright(` database endpoint: \t${dynamoConfig.endpoint}`)) console.log(clc.blackBright(` region: \t\t${dynamoConfig.region}`)) console.log(clc.blackBright(` accessKey: \t\t${dynamoConfig.accessKeyId}\n`)) return dynamoConfig } exports.createServer = (dynamodbClient, docClient, expressInstance = express()) => { const app = expressInstance app.set('json spaces', 2) app.set('view engine', 'ejs') app.set('views', path.resolve(__dirname, '..', 'views')) if (!dynamodbClient || !docClient) { if (!dynamodbClient) { dynamodbClient = new DynamoDBClient(createAwsConfig()) } docClient = docClient || DynamoDBDocumentClient.from(dynamodbClient) } const listTables = (...args) => dynamodbClient.send(new ListTablesCommand(...args)) const describeTable = (...args) => dynamodbClient.send(new DescribeTableCommand(...args)) const getItem = (...args) => docClient.send(new GetCommand(...args)) const putItem = (...args) => docClient.send(new PutCommand(...args)) const deleteItem = (...args) => docClient.send(new DeleteCommand(...args)) app.use(errorhandler()) app.use('/assets', express.static(path.join(__dirname, '..', 'public'))) app.use(cookieParser()) app.use(function (req, res, next) { const { theme = DEFAULT_THEME } = req.cookies res.locals = { theme, } next() }) const listAllTables = (lastEvaluatedTableName, tableNames) => { return listTables({ ExclusiveStartTableName: lastEvaluatedTableName }) .then(data => { tableNames = tableNames.concat(data.TableNames) if (typeof data.LastEvaluatedTableName !== 'undefined') { return listAllTables(data.LastEvaluatedTableName, tableNames) } return Promise.all( tableNames.map(TableName => { return describeTable({ TableName }).then(data => data.Table) }) ) }) } app.get('/', asyncMiddleware((req, res) => { return listAllTables(null, []) .then(data => { res.render('tables', { data }) }) })) app.get('/api/tables', (req, res) => { return listAllTables(null, []) .then(data => { res.send(data) }) }) app.get('/create-table', (req, res) => { res.render('create-table', {}) }) app.post( '/create-table', bodyParser.json({ limit: '500kb' }), asyncMiddleware((req, res) => { const attributeDefinitions = [ { AttributeName: req.body.HashAttributeName, AttributeType: req.body.HashAttributeType } ] const keySchema = [ { AttributeName: req.body.HashAttributeName, KeyType: 'HASH' } ] if (req.body.RangeAttributeName) { attributeDefinitions.push({ AttributeName: req.body.RangeAttributeName, AttributeType: req.body.RangeAttributeType }) keySchema.push({ AttributeName: req.body.RangeAttributeName, KeyType: 'RANGE' }) } let globalSecondaryIndexes = [] let localSecondaryIndexes = [] if (req.body.SecondaryIndexes) { req.body.SecondaryIndexes.forEach(secondaryIndex => { const secondaryIndexKeySchema = [ { AttributeName: secondaryIndex.HashAttributeName, KeyType: 'HASH' } ] if ( isAttributeNotAlreadyCreated(attributeDefinitions, secondaryIndex.HashAttributeName)) { attributeDefinitions.push({ AttributeName: secondaryIndex.HashAttributeName, AttributeType: secondaryIndex.HashAttributeType }) } if (secondaryIndex.RangeAttributeName) { if (isAttributeNotAlreadyCreated( attributeDefinitions, secondaryIndex.RangeAttributeName)) { attributeDefinitions.push({ AttributeName: secondaryIndex.RangeAttributeName, AttributeType: secondaryIndex.RangeAttributeType }) } secondaryIndexKeySchema.push({ AttributeName: secondaryIndex.RangeAttributeName, KeyType: 'RANGE' }) } const index = { IndexName: secondaryIndex.IndexName, KeySchema: secondaryIndexKeySchema, Projection: { ProjectionType: 'ALL' } } if (secondaryIndex.IndexType === 'global') { index.ProvisionedThroughput = { ReadCapacityUnits: Number(req.body.ReadCapacityUnits), WriteCapacityUnits: Number(req.body.WriteCapacityUnits) } globalSecondaryIndexes.push(index) } else { localSecondaryIndexes.push(index) } }) } if (localSecondaryIndexes === undefined || localSecondaryIndexes.length === 0) { localSecondaryIndexes = undefined } if (globalSecondaryIndexes === undefined || globalSecondaryIndexes.length === 0) { globalSecondaryIndexes = undefined } const CreateTablePromise = dynamodbClient.send(new CreateTableCommand({ TableName: req.body.TableName, ProvisionedThroughput: { ReadCapacityUnits: Number(req.body.ReadCapacityUnits), WriteCapacityUnits: Number(req.body.WriteCapacityUnits) }, GlobalSecondaryIndexes: globalSecondaryIndexes, LocalSecondaryIndexes: localSecondaryIndexes, KeySchema: keySchema, AttributeDefinitions: attributeDefinitions })) return CreateTablePromise .then(() => { res.status(204).end() }).catch(error => { res.status(400).send(error) }) }) ) app.delete('/tables', asyncMiddleware(async (req, res) => { const tablesList = await listAllTables(null, []) if (tablesList.length === 0) { return res.send('There are no tables to delete') } await Promise.all(tablesList.map(table => dynamodbClient.send(new DeleteTableCommand({ TableName: table.TableName })) )) return res.send('Tables deleted') })) app.delete('/tables-purge', asyncMiddleware(async (req, res) => { const tablesList = await listAllTables(null, []) if (tablesList.length === 0) { return res.send('There are no tables to purge') } await Promise.all(tablesList.map(table => purgeTable(table.TableName, dynamodbClient))) return res.send('Tables purged') })) app.delete('/tables/:TableName', asyncMiddleware((req, res) => { const TableName = req.params.TableName const deleteTablePromise = dynamodbClient.send(new DeleteTableCommand({ TableName })) return deleteTablePromise .then(() => { res.status(204).end() }) })) app.delete('/tables/:TableName/all', asyncMiddleware((req, res) => { return purgeTable(req.params.TableName, dynamodbClient) .then(() => { res.status(200).end() }) })) app.get('/tables/:TableName/get', asyncMiddleware((req, res) => { const TableName = req.params.TableName if (req.query.hash) { if (req.query.range) { return res.redirect( `/tables/${encodeURIComponent(TableName)}/items/${encodeURIComponent(req.query.hash)}, ${encodeURIComponent(req.query.range)}` ) } else { return res.redirect( `/tables/${encodeURIComponent(TableName)}/items/${encodeURIComponent(req.query.hash)}`) } } return describeTable({ TableName }) .then(description => { const hashKey = description.Table.KeySchema.find(schema => { return schema.KeyType === 'HASH' }) if (hashKey) { hashKey.AttributeType = description.Table.AttributeDefinitions.find( definition => { return definition.AttributeName === hashKey.AttributeName } ).AttributeType } const rangeKey = description.Table.KeySchema.find(schema => { return schema.KeyType === 'RANGE' }) if (rangeKey) { rangeKey.AttributeType = description.Table.AttributeDefinitions.find( definition => { return definition.AttributeName === rangeKey.AttributeName } ).AttributeType } res.render( 'get', Object.assign({}, description, { hashKey, rangeKey }) ) }) })) const getPage = (docClient, keySchema, TableName, scanParams, pageSize, startKey, operationType) => { const pageItems = [] function onNewItems(items, lastStartKey) { for (let i = 0; i < items.length && pageItems.length < pageSize + 1; i++) { pageItems.push(items[i]) } // If there is more items to query (!lastStartKey) then don't stop until // we are over pageSize count. Stopping at exactly pageSize count would // not extract key of last item later and make pagination not work. return pageItems.length > pageSize || !lastStartKey } return doSearch(docClient, TableName, scanParams, 10, startKey, onNewItems, operationType) .then(items => { let nextKey = null if (items.length > pageSize) { items = items.slice(0, pageSize) nextKey = extractKey(items[pageSize - 1], keySchema) } return { pageItems: items, nextKey, } }) } app.get('/tables/:TableName', asyncMiddleware((req, res) => { const TableName = req.params.TableName req.query = pickBy(req.query) return describeTable({ TableName }) .then(description => { const pageNum = req.query.pageNum ? parseInt(req.query.pageNum) : 1 const data = Object.assign({}, description, { query: req.query, pageNum, operators: { '=': '=', '<>': '≠', '>=': '>=', '<=': '<=', '>': '>', '<': '<', 'begins_with': 'begins_with' }, attributeTypes: { 'S': 'String', 'N': 'Number', }, }) res.render('scan', data) }) })) app.get('/tables/:TableName/items', asyncMiddleware((req, res) => { const TableName = req.params.TableName req.query = pickBy(req.query) const filters = req.query.filters ? JSON.parse(req.query.filters) : {} return describeTable({ TableName }) .then(description => { const ExclusiveStartKey = req.query.startKey ? JSON.parse(req.query.startKey) : {} const pageNum = req.query.pageNum ? parseInt(req.query.pageNum) : 1 const ExpressionAttributeNames = {} const ExpressionAttributeValues = {} const FilterExpressions = [] const KeyConditions = [] const KeyConditionExpression = [] const queryableSelection = req.query.queryableSelection || 'table' let indexBeingUsed = null if (req.query.operationType === 'query') { if (queryableSelection === 'table') { indexBeingUsed = description.Table } else if (description.Table.GlobalSecondaryIndexes) { indexBeingUsed = description.Table.GlobalSecondaryIndexes.find((index) => { return index.IndexName === req.query.queryableSelection }) } } // Create a variable to uniquely identify each expression attribute let i = 0 for (const key in filters) { if (filters[key].type === 'N') { filters[key].value = Number(filters[key].value) } ExpressionAttributeNames[`#key${i}`] = key ExpressionAttributeValues[`:key${i}`] = filters[key].value const matchedKeySchema = indexBeingUsed ? indexBeingUsed.KeySchema.find( (keySchemaItem) => keySchemaItem.AttributeName === key) : undefined if (matchedKeySchema) { // Only the Range key can support begins_with operator if (matchedKeySchema.KeyType === 'RANGE' && filters[key].operator === 'begins_with') { KeyConditionExpression.push(`${filters[key].operator} ( #key${i} , :key${i})`) } else { KeyConditionExpression.push(`#key${i} ${filters[key].operator} :key${i}`) } } else { ExpressionAttributeNames[`#key${i}`] = key ExpressionAttributeValues[`:key${i}`] = filters[key].value if (filters[key].operator === 'begins_with') { FilterExpressions.push(`${filters[key].operator} ( #key${i} , :key${i})`) } else { FilterExpressions.push(`#key${i} ${filters[key].operator} :key${i}`) } } // Increment the unique ID variable i = i + 1 } const params = pickBy({ TableName, FilterExpression: FilterExpressions.length ? FilterExpressions.join(' AND ') : undefined, ExpressionAttributeNames: Object.keys(ExpressionAttributeNames).length ? ExpressionAttributeNames : undefined, ExpressionAttributeValues: Object.keys(ExpressionAttributeValues).length ? ExpressionAttributeValues : undefined, KeyConditions: Object.keys(KeyConditions).length ? KeyConditions : undefined, KeyConditionExpression: KeyConditionExpression.length ? KeyConditionExpression.join(' AND ') : undefined, }) if (req.query.queryableSelection && req.query.queryableSelection !== 'table') { params.IndexName = req.query.queryableSelection } const startKey = Object.keys(ExclusiveStartKey).length ? ExclusiveStartKey : undefined const pageSize = req.query.pageSize || 25 return getPage( docClient, description.Table.KeySchema, TableName, params, pageSize, startKey, req.query.operationType) .then(results => { const { pageItems, nextKey } = results const nextKeyParam = nextKey ? encodeURIComponent(JSON.stringify(nextKey)) : null const primaryKeys = description.Table.KeySchema.map( schema => schema.AttributeName) // Primary keys are listed first. const uniqueKeys = [ ...primaryKeys, ...extractKeysForItems(pageItems).filter(key => !primaryKeys.includes(key)), ] // Append the item key. for (const item of pageItems) { item.__key = extractKey(item, description.Table.KeySchema) } const data = Object.assign({}, description, { query: req.query, pageNum, prevKey: encodeURIComponent(req.query.prevKey || ''), startKey: encodeURIComponent(req.query.startKey || ''), nextKey: nextKeyParam, filterQueryString: encodeURIComponent(req.query.filters || ''), Items: pageItems, uniqueKeys, }) res.json(data) }) .catch(error => { res.status(400).send((error.code ? '[' + error.code + '] ' : '') + error.message) }) }) })) app.get('/tables/:TableName/meta', asyncMiddleware((req, res) => { const TableName = req.params.TableName return Promise.all([ describeTable({ TableName }), () => docClient.send(new ScanCommand({ TableName })) ]) .then(([description, items]) => { const data = Object.assign({}, description, items) res.render('meta', data) }) })) app.delete('/tables/:TableName/items/:key', asyncMiddleware((req, res) => { const TableName = req.params.TableName return describeTable({ TableName }) .then(result => { const params = { TableName, Key: parseKey(req.params.key, result.Table) } return deleteItem(params).then(() => { res.status(204).end() }) }) })) app.get('/tables/:TableName/add-item', asyncMiddleware((req, res) => { const TableName = req.params.TableName return describeTable({ TableName }) .then(result => { const table = result.Table const Item = {} table.KeySchema.forEach(key => { const definition = table.AttributeDefinitions.find(attribute => { return attribute.AttributeName === key.AttributeName }) Item[key.AttributeName] = definition.AttributeType === 'S' ? '' : 0 }) res.render('item', { Table: table, TableName: req.params.TableName, Item: Item, isNew: true }) }) })) app.get('/tables/:TableName/items/:key', asyncMiddleware((req, res) => { const TableName = req.params.TableName return describeTable({ TableName }) .then(result => { const params = { TableName, Key: parseKey(req.params.key, result.Table) } return getItem(params).then(response => { if (!response.Item) { return res.status(404).send('Not found') } res.render('item', { Table: result.Table, TableName: req.params.TableName, Item: response.Item, isNew: false }) }) }) })) app.put( '/tables/:TableName/add-item', bodyParser.json({ limit: '500kb' }), asyncMiddleware((req, res) => { const TableName = req.params.TableName return describeTable({ TableName }) .then(description => { const params = { TableName, Item: req.body } return putItem(params).then(() => { const Key = extractKey(req.body, description.Table.KeySchema) const params = { TableName, Key } return getItem(params).then(response => { if (!response.Item) { return res.status(404).send('Not found') } return res.json(Key) }) }) }) })) app.put( '/tables/:TableName/items/:key', bodyParser.json({ limit: '500kb' }), asyncMiddleware((req, res) => { const TableName = req.params.TableName return describeTable({ TableName }) .then(result => { const params = { TableName, Item: req.body } return putItem(params).then(() => { const params = { TableName, Key: parseKey(req.params.key, result.Table) } return getItem(params).then(response => { return res.json(response.Item) }) }) }) })) app.use((err, req, res, next) => { console.error(err) next(err) }) return app } function isAttributeNotAlreadyCreated(attributeDefinitions, attributeName) { return !attributeDefinitions .find(attributeDefinition => attributeDefinition.AttributeName === attributeName) }