type2docfx
Version:
A tool to convert json format output from TypeDoc to universal reference model for DocFx to consume.
310 lines (272 loc) • 12.5 kB
text/typescript
/**
* @module botbuilder-azure
*/
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { Storage, StoreItems, StoreItem } from 'botbuilder';
import * as azure from 'azure-storage';
import { flatten, unflatten } from 'flat';
const EntityGenerator = azure.TableUtilities.entityGenerator;
/** Additional settings for configuring an instance of [TableStorage](../classes/botbuilder_azure_v4.tablestorage.html). */
export interface TableStorageSettings {
/**
* Name of the table to use for storage.
* Check table name rules: https://docs.microsoft.com/en-us/rest/api/storageservices/Understanding-the-Table-Service-Data-Model?redirectedfrom=MSDN#table-names
*/
tableName: string;
/** Storage access key. */
storageAccessKey?: string;
/** (Optional) storage account to use or connection string. */
storageAccountOrConnectionString?: string;
/** (Optional) azure storage host. */
host?: azure.StorageHost;
}
/**
* Map of already initialized tables. Key = tableName, Value = Promise with TableResult creation.
*/
let checkedTables: { [name: string]: Promise<azure.TableService.TableResult>; } = {};
/**
* Middleware that implements an Azure Table based storage provider for a bot.
*
* **Usage Example**
*
* ```javascript
* const BotBuilderAzure = require('botbuilder-azure');
* const storage = new BotBuilderAzure.TableStorage({
* storageAccountOrConnectionString: 'UseDevelopmentStorage=true',
* tableName: 'mybotstate'
* });
*
* // Add state middleware
* const state = new BotStateManager(storage);
* adapter.use(state);
* ```
*/
export class TableStorage implements Storage {
private settings: TableStorageSettings;
private tableService: TableServiceAsync;
/**
* Creates a new instance of the storage provider.
*
* @param settings (Optional) Setting to configure the provider.
*/
public constructor(settings: TableStorageSettings) {
if (!settings) {
throw new Error('The settings parameter is required.');
}
// https://docs.microsoft.com/en-us/rest/api/storageservices/Understanding-the-Table-Service-Data-Model?redirectedfrom=MSDN#table-names
if (!/^[A-Za-z][A-Za-z0-9]{2,62}$/.test(settings.tableName)) {
throw new Error('The table name contains invalid characters.')
}
this.settings = Object.assign({}, settings);
this.tableService = this.createTableService(this.settings.storageAccountOrConnectionString, this.settings.storageAccessKey, this.settings.host)
}
/** Ensure the table is created. */
public ensureTable(): Promise<azure.TableService.TableResult> {
if (!checkedTables[this.settings.tableName])
checkedTables[this.settings.tableName] = this.tableService.createTableIfNotExistsAsync(this.settings.tableName);
return checkedTables[this.settings.tableName];
}
/** Delete backing table (mostly used for unit testing.) */
public deleteTable(): Promise<boolean> {
if (checkedTables[this.settings.tableName])
delete checkedTables[this.settings.tableName];
return this.tableService.deleteTableIfExistsAsync(this.settings.tableName);
}
/**
* Loads store items from storage
*
* @param keys Array of item keys to read from the store.
**/
public read(keys: string[]): Promise<StoreItems> {
if (!keys || !keys.length) {
throw new Error('Please provide at least one key to read from storage.');
}
return this.ensureTable().then(() => {
var reads = keys.map(key => {
let pk = this.sanitizeKey(key);
return this.tableService.retrieveEntityAsync<any>(this.settings.tableName, pk, '', { entityResolver: entityResolver })
.then(result => {
let value = unflatten(result, flattenOptions);
value.eTag = value['.metadata'].etag;
// remove TableRow Properties from storeItem
['PartitionKey', 'RowKey', '.metadata'].forEach(k => delete value[k]);
return { key, value };
}).catch(handleNotFoundWith({ key, value: null }));
});
return Promise.all(reads)
.then(items => items
.filter(prop => prop.value !== null)
.reduce(propsReducer, {})); // as StoreItems
});
};
/**
* Saves store items to storage.
*
* @param changes Map of items to write to storage.
**/
public write(changes: StoreItems): Promise<void> {
if (!changes) {
throw new Error('Please provide a StoreItems with changes to persist.')
}
// Check for bogus etags
Object.keys(changes).map(key => {
let eTag = changes[key].eTag;
if (eTag != null && eTag.trim() === "") {
throw new Error('Etag empty for key ' + key);
}
});
return this.ensureTable().then(() => {
var writes = Object.keys(changes).map(key => {
let storeItem: StoreItem = changes[key];
// flatten the object graph into single columns
let flat = flatten(storeItem, flattenOptions);
let entity = asEntityDescriptor(flat);
delete entity.eTag;
// add PK/RK and ETag
let pk = this.sanitizeKey(key);
entity.PartitionKey = EntityGenerator.String(pk);
entity.RowKey = EntityGenerator.String('');
entity['.metadata'] = { etag: storeItem.eTag };
if (storeItem.eTag == null || storeItem.eTag === "*") {
// if new item or * then insert or replace unconditionaly
return this.tableService.insertOrReplaceEntityAsync(this.settings.tableName, entity);
}
else if (storeItem.eTag.length > 0) {
// if we have an etag, do opt. concurrency replace
return this.tableService.replaceEntityAsync(this.settings.tableName, entity);
}
});
return Promise.all(writes)
.then(() => { }); // void
});
};
/**
* Removes store items from storage
*
* @param keys Array of item keys to remove from the store.
**/
public delete(keys: string[]): Promise<void> {
if (!keys || !keys.length) return Promise.resolve();
return this.ensureTable().then(() => {
let deletes = keys.map(key => {
let pk = this.sanitizeKey(key);
let entity = {
PartitionKey: EntityGenerator.String(pk),
RowKey: EntityGenerator.String('')
};
entity['.metadata'] = { etag: '*' };
return this.tableService
.deleteEntityAsync(this.settings.tableName, entity)
.catch(handleNotFoundWith(null));
});
return Promise.all(deletes)
.then(() => { }); // void
});
}
private sanitizeKey(key: string): string {
let badChars = ['\\', '?', '/', '#', '\t', '\n', '\r'];
let sb = '';
for (let iCh = 0; iCh < key.length; iCh++) {
let ch = key[iCh];
let isBad: boolean = false;
for (let iBad in badChars) {
let badChar = badChars[iBad];
if (ch === badChar) {
sb += '%' + ch.charCodeAt(0).toString(16);
isBad = true;
break;
}
}
if (!isBad)
sb += ch;
}
return sb;
}
// create TableServiceAsync instance based on connection config
private createTableService(storageAccountOrConnectionString: string, storageAccessKey: string, host: any): TableServiceAsync {
const tableService = storageAccountOrConnectionString ? azure.createTableService(storageAccountOrConnectionString, storageAccessKey, host) : azure.createTableService();
// create TableServiceAsync by using denodeify to create promise wrappers around cb functions
return {
createTableIfNotExistsAsync: this.denodeify(tableService, tableService.createTableIfNotExists),
deleteTableIfExistsAsync: this.denodeify(tableService, tableService.deleteTableIfExists),
retrieveEntityAsync: this.denodeify(tableService, tableService.retrieveEntity),
insertOrReplaceEntityAsync: this.denodeify(tableService, tableService.insertOrReplaceEntity),
replaceEntityAsync: this.denodeify(tableService, tableService.replaceEntity),
deleteEntityAsync: this.denodeify(tableService, tableService.deleteEntity)
} as any;
}
// turn a cb based azure method into a Promisified one
private denodeify<T>(thisArg: any, fn: Function): (...args: any[]) => Promise<T> {
return (...args: any[]) => {
return new Promise<T>((resolve, reject) => {
args.push((error: Error, result: any) => (error) ? reject(error) : resolve(result));
fn.apply(thisArg, args);
});
};
}
}
// Promise based methods created using denodeify function
interface TableServiceAsync extends azure.TableService {
createTableIfNotExistsAsync(table: string): Promise<azure.TableService.TableResult>;
deleteTableIfExistsAsync(table: string): Promise<boolean>;
retrieveEntityAsync<T>(table: string, partitionKey: string, rowKey: string, options: any): Promise<T>;
replaceEntityAsync<T>(table: string, entityDescriptor: T): Promise<azure.TableService.EntityMetadata>;
insertOrReplaceEntityAsync<T>(table: string, entityDescriptor: T): Promise<azure.TableService.EntityMetadata>;
deleteEntityAsync<T>(table: string, entityDescriptor: T): Promise<void>;
}
// Handle service 404 and 204 responses as null returns, throw any other error
const handleNotFoundWith = (defaultValue: any) => (error) => {
// return defaultValue when not found or no content
if (error.statusCode === 404 || error.statusCode === 204)
return defaultValue;
else
throw error;
};
// Convert an object into EDM types
const asEntityDescriptor = (obj): any => {
return Object.keys(obj)
.map(key => ({
key,
value: asEntityProperty(obj[key])
})).reduce(propsReducer, {});
};
const asEntityProperty = (value) => {
switch (getTypeOf(value)) {
case 'date': return EntityGenerator.DateTime(value);
case 'boolean': return EntityGenerator.Boolean(value);
case 'number':
let maxSafeInt32 = Math.pow(2, 32) - 1;
if (isFloat(value)) return EntityGenerator.Double(value);
if (Math.abs(value) > maxSafeInt32) return EntityGenerator.Int64(value);
return EntityGenerator.Int32(value);
case 'string':
default:
return EntityGenerator.String(value);
}
};
const getTypeOf = (obj) =>
({}).toString.call(obj).match(/\s([a-zA-Z]+)/)[1].toLowerCase();
const isFloat = (n) => Number(n) === n && n % 1 !== 0;
// Convert EDM types back to an JS object
const entityResolver = (entity) => {
return Object.keys(entity)
.map(key => ({ key, value: getEdmValue(entity[key]) }))
.reduce(propsReducer, {});
};
const getEdmValue = (entityValue) => {
return entityValue.$ === azure.TableUtilities.EdmType.INT64
? Number(entityValue._)
: entityValue._;
}
// Reduces pairs for key/value into an object (e.g.: StoreItems)
const propsReducer = (resolved, propValue: { key, value }): any => {
resolved[propValue.key] = propValue.value;
return resolved;
};
// flat/flatten options to use '_' as delimiter (same as C#'s TableEntity.Flatten default delimiter)
const flattenOptions = {
delimiter: '_'
};