diff --git a/package.json b/package.json index 698bcbf1..77ced084 100644 --- a/package.json +++ b/package.json @@ -214,6 +214,8 @@ "devDependencies": { "@types/mocha": "^2.2.32", "@types/node": "^8.0.28", + "@types/request-promise": "^4.1.38", + "@types/rimraf": "^2.0.2", "mocha": "^2.3.3", "tslint": "^5.7.0", "tslint-microsoft-contrib": "5.0.1", @@ -221,11 +223,14 @@ "vscode": "^1.0.0" }, "dependencies": { + "azure-arm-cosmosdb": "^1.0.0-preview", "azure-arm-resource": "^2.0.0-preview", "azure-arm-website": "^1.0.0-preview", "ms-rest": "^2.2.2", "ms-rest-azure": "^2.3.1", "opn": "^5.1.0", + "request-promise": "^4.2.2", + "rimraf": "^2.6.2", "vscode-extension-telemetry": "^0.0.6", "vscode-nls": "^2.0.2", "vscode-azureappservice": "^0.1.1" diff --git a/src/AzureFunctionsExplorer.ts b/src/AzureFunctionsExplorer.ts index 7c89d4b6..aa8c0a8c 100644 --- a/src/AzureFunctionsExplorer.ts +++ b/src/AzureFunctionsExplorer.ts @@ -5,22 +5,25 @@ import { Event, EventEmitter, TreeDataProvider, TreeItem } from 'vscode'; import { AzureAccount, AzureResourceFilter } from './azure-account.api'; +import { IUserInterface, PickWithData } from './IUserInterface'; import { localize } from './localize'; import { NodeBase } from './nodes/NodeBase'; import { SubscriptionNode } from './nodes/SubscriptionNode'; -import * as uiUtil from './utils/ui'; +import { VSCodeUI } from './VSCodeUI'; export class AzureFunctionsExplorer implements TreeDataProvider { - private onDidChangeTreeDataEmitter: EventEmitter = new EventEmitter(); - private azureAccount: AzureAccount; - private rootNodes: NodeBase[] = []; + private _onDidChangeTreeDataEmitter: EventEmitter = new EventEmitter(); + private _azureAccount: AzureAccount; + private _rootNodes: NodeBase[] = []; + private _ui: IUserInterface; - constructor(azureAccount: AzureAccount) { - this.azureAccount = azureAccount; + constructor(azureAccount: AzureAccount, ui: IUserInterface = new VSCodeUI()) { + this._azureAccount = azureAccount; + this._ui = ui; } public get onDidChangeTreeData(): Event { - return this.onDidChangeTreeDataEmitter.event; + return this._onDidChangeTreeDataEmitter.event; } public getTreeItem(node: NodeBase): TreeItem { @@ -31,40 +34,40 @@ export class AzureFunctionsExplorer implements TreeDataProvider { if (node) { return await node.getChildren(); } else { // Root of the explorer - this.rootNodes = []; + this._rootNodes = []; - if (this.azureAccount.status === 'Initializing' || this.azureAccount.status === 'LoggingIn') { + if (this._azureAccount.status === 'Initializing' || this._azureAccount.status === 'LoggingIn') { return [new NodeBase('azureFunctionsLoading', localize('azFunc.loadingNode', 'Loading...'))]; - } else if (this.azureAccount.status === 'LoggedOut') { + } else if (this._azureAccount.status === 'LoggedOut') { return [new NodeBase('azureFunctionsSignInToAzure', localize('azFunc.signInNode', 'Sign in to Azure...'), undefined, 'azure-account.login')]; - } else if (this.azureAccount.filters.length === 0) { + } else if (this._azureAccount.filters.length === 0) { return [new NodeBase('azureFunctionsNoSubscriptions', localize('azFunc.noSubscriptionsNode', 'No subscriptions found. Edit filters...'), undefined, 'azure-account.selectSubscriptions')]; } else { - this.rootNodes = this.azureAccount.filters.map((filter: AzureResourceFilter) => SubscriptionNode.CREATE(filter)); + this._rootNodes = this._azureAccount.filters.map((filter: AzureResourceFilter) => SubscriptionNode.CREATE(filter)); - return this.rootNodes; + return this._rootNodes; } } } public refresh(node?: NodeBase): void { - this.onDidChangeTreeDataEmitter.fire(node); + this._onDidChangeTreeDataEmitter.fire(node); } public async showNodePicker(expectedContextValue: string): Promise { let childType: string | undefined = 'Subscription'; - let quickPicksTask: Promise[]> = Promise.resolve(this.rootNodes.map((c: NodeBase) => new uiUtil.PickWithData(c, c.label))); + let quickPicksTask: Promise[]> = Promise.resolve(this._rootNodes.map((c: NodeBase) => new PickWithData(c, c.label))); while (childType) { - const pick: uiUtil.PickWithData = await uiUtil.showQuickPick(quickPicksTask, localize('azFunc.selectNode', 'Select a {0}', childType)); + const pick: PickWithData = await this._ui.showQuickPick(quickPicksTask, localize('azFunc.selectNode', 'Select a {0}', childType)); const node: NodeBase = pick.data; if (node.contextValue === expectedContextValue) { return node; } childType = node.childType; - quickPicksTask = node.getChildren(false).then((nodes: NodeBase[]): uiUtil.PickWithData[] => { - return nodes.map((c: NodeBase) => new uiUtil.PickWithData(c, c.label)); + quickPicksTask = node.getChildren(false).then((nodes: NodeBase[]): PickWithData[] => { + return nodes.map((c: NodeBase) => new PickWithData(c, c.label)); }); } diff --git a/src/IUserInterface.ts b/src/IUserInterface.ts new file mode 100644 index 00000000..95595930 --- /dev/null +++ b/src/IUserInterface.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { QuickPickItem } from 'vscode'; + +export interface IUserInterface { + showQuickPick(items: PickWithData[] | Thenable[]>, placeHolder: string, ignoreFocusOut?: boolean): Promise>; + showQuickPick(items: Pick[] | Thenable, placeHolder: string, ignoreFocusOut?: boolean): Promise; + + showInputBox(placeHolder: string, prompt: string, ignoreFocusOut?: boolean, validateInput?: (s: string) => string | undefined | null, value?: string): Promise; + + showFolderDialog(): Promise; +} + +export class Pick implements QuickPickItem { + public readonly description: string; + public readonly label: string; + constructor(label: string, description?: string) { + this.label = label; + this.description = description ? description : ''; + } +} + +export class PickWithData extends Pick { + public readonly data: T; + constructor(data: T, label: string, description?: string) { + super(label, description); + this.data = data; + } +} diff --git a/src/VSCodeUI.ts b/src/VSCodeUI.ts new file mode 100644 index 00000000..bcf4c246 --- /dev/null +++ b/src/VSCodeUI.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as errors from './errors'; +import { IUserInterface, Pick, PickWithData } from './IUserInterface'; +import { localize } from './localize'; + +export class VSCodeUI implements IUserInterface { + public async showQuickPick(items: PickWithData[] | Thenable[]>, placeHolder: string, ignoreFocusOut?: boolean): Promise>; + public async showQuickPick(items: Pick[] | Thenable, placeHolder: string, ignoreFocusOut?: boolean): Promise; + public async showQuickPick(items: vscode.QuickPickItem[] | Thenable, placeHolder: string, ignoreFocusOut: boolean = false): Promise { + const options: vscode.QuickPickOptions = { + placeHolder: placeHolder, + ignoreFocusOut: ignoreFocusOut + }; + const result: vscode.QuickPickItem | undefined = await vscode.window.showQuickPick(items, options); + + if (!result) { + throw new errors.UserCancelledError(); + } else { + return result; + } + } + + public async showInputBox(placeHolder: string, prompt: string, ignoreFocusOut: boolean = false, validateInput?: (s: string) => string | undefined | null, defaultValue?: string): Promise { + const options: vscode.InputBoxOptions = { + placeHolder: placeHolder, + prompt: prompt, + validateInput: validateInput, + ignoreFocusOut: ignoreFocusOut, + value: defaultValue + }; + const result: string | undefined = await vscode.window.showInputBox(options); + + if (!result) { + throw new errors.UserCancelledError(); + } else { + return result; + } + } + + public async showFolderDialog(): Promise { + const defaultUri: vscode.Uri | undefined = vscode.workspace.rootPath ? vscode.Uri.file(vscode.workspace.rootPath) : undefined; + const options: vscode.OpenDialogOptions = { + defaultUri: defaultUri, + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: localize('azFunc.select', 'Select') + }; + const result: vscode.Uri[] | undefined = await vscode.window.showOpenDialog(options); + + if (!result || result.length === 0) { + throw new errors.UserCancelledError(); + } else { + return result[0].fsPath; + } + } +} diff --git a/src/commands/createFunction.ts b/src/commands/createFunction.ts index 42b5c7ff..45d01304 100644 --- a/src/commands/createFunction.ts +++ b/src/commands/createFunction.ts @@ -3,14 +3,27 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +// tslint:disable-next-line:no-require-imports +import CosmosDBManagementClient = require('azure-arm-cosmosdb'); +import { DatabaseAccount, DatabaseAccountListKeysResult } from 'azure-arm-cosmosdb/lib/models'; import * as fs from 'fs'; +import { ServiceClientCredentials } from 'ms-rest'; +import { BaseResource } from 'ms-rest-azure'; import * as path from 'path'; import * as vscode from 'vscode'; +import { AzureAccount, AzureResourceFilter } from '../azure-account.api'; import * as errors from '../errors'; +import { UserCancelledError } from '../errors'; import * as FunctionsCli from '../functions-cli'; +import { IUserInterface, Pick, PickWithData } from '../IUserInterface'; import { localize } from '../localize'; -import * as uiUtil from '../utils/ui'; +import { ConfigSetting, ResourceType, ValueType } from '../templates/ConfigSetting'; +import { Template } from '../templates/Template'; +import { TemplateData } from '../templates/TemplateData'; +import * as azUtil from '../utils/azure'; +import * as fsUtil from '../utils/fs'; import * as workspaceUtil from '../utils/workspace'; +import { VSCodeUI } from '../VSCodeUI'; const expectedFunctionAppFiles: string[] = [ 'host.json', @@ -22,7 +35,7 @@ function getMissingFunctionAppFiles(rootPath: string): string[] { return expectedFunctionAppFiles.filter((file: string) => !fs.existsSync(path.join(rootPath, file))); } -function validateTemplateName(rootPath: string, name: string): string | undefined { +function validateTemplateName(rootPath: string, name: string | undefined): string | undefined { if (!name) { return localize('azFunc.emptyTemplateNameError', 'The template name cannot be empty.'); } else if (fs.existsSync(path.join(rootPath, name))) { @@ -32,9 +45,7 @@ function validateTemplateName(rootPath: string, name: string): string | undefine } } -export async function createFunction(outputChannel: vscode.OutputChannel): Promise { - const functionAppPath: string = await workspaceUtil.selectWorkspaceFolder(localize('azFunc.selectFunctionAppFolderExisting', 'Select the folder containing your function app')); - +async function validateIsFunctionApp(outputChannel: vscode.OutputChannel, functionAppPath: string): Promise { const missingFiles: string[] = getMissingFunctionAppFiles(functionAppPath); if (missingFiles.length !== 0) { const yes: string = localize('azFunc.yes', 'Yes'); @@ -47,20 +58,171 @@ export async function createFunction(outputChannel: vscode.OutputChannel): Promi throw new errors.UserCancelledError(); } } +} - const templates: uiUtil.Pick[] = [ - new uiUtil.Pick('BlobTrigger'), - new uiUtil.Pick('HttpTrigger'), - new uiUtil.Pick('QueueTrigger'), - new uiUtil.Pick('TimerTrigger') - ]; - const template: uiUtil.Pick = await uiUtil.showQuickPick(templates, localize('azFunc.selectFuncTemplate', 'Select a function template')); - - const placeHolder: string = localize('azFunc.funcNamePlaceholder', 'Function Name'); +async function promptForFunctionName(ui: IUserInterface, functionAppPath: string, template: Template): Promise { + const defaultFunctionName: string | undefined = await fsUtil.getUniqueFsPath(functionAppPath, template.defaultFunctionName); const prompt: string = localize('azFunc.funcNamePrompt', 'Provide a function name'); - const name: string = await uiUtil.showInputBox(placeHolder, prompt, false, (s: string) => validateTemplateName(functionAppPath, s)); + const placeHolder: string = localize('azFunc.funcNamePlaceholder', 'Function name'); - await FunctionsCli.createFunction(outputChannel, functionAppPath, template.label, name); - const newFileUri: vscode.Uri = vscode.Uri.file(path.join(functionAppPath, name, 'index.js')); + return await ui.showInputBox(placeHolder, prompt, false, (s: string) => validateTemplateName(functionAppPath, s), defaultFunctionName || template.defaultFunctionName); +} + +async function promptForSetting(ui: IUserInterface, azureAccount: AzureAccount, functionAppPath: string, setting: ConfigSetting, defaultValue?: string): Promise { + if (setting.resourceType !== undefined) { + const subscription: AzureResourceFilter | undefined = await promptForSubscription(ui, azureAccount); + + if (subscription) { + const subscriptionId: string | undefined = subscription.subscription.subscriptionId; + + if (subscriptionId) { + const credentials: ServiceClientCredentials = subscription.session.credentials; + + switch (setting.resourceType) { + case ResourceType.DocumentDB: + return await promptForDocumentDB(ui, functionAppPath, credentials, subscriptionId, setting); + default: + } + } + } + } else { + switch (setting.valueType) { + case ValueType.boolean: + return await promptForBooleanSetting(ui, setting); + default: + } + } + + // Default to 'string' type for any setting that isn't supported + return await promptForStringSetting(ui, setting, defaultValue); +} + +async function promptForSubscription(ui: IUserInterface, azureAccount: AzureAccount): Promise { + if (azureAccount.filters.length === 0) { + return undefined; + } else if (azureAccount.filters.length === 1) { + return azureAccount.filters[0]; + } else { + const subscriptionPicks: PickWithData[] = []; + azureAccount.filters.forEach((f: AzureResourceFilter) => { + const subscriptionId: string | undefined = f.subscription.subscriptionId; + if (subscriptionId) { + const label: string = f.subscription.displayName || subscriptionId; + subscriptionPicks.push(new PickWithData(f, label, subscriptionId)); + } + }); + const placeHolder: string = localize('azFunc.selectSubscription', 'Select a Subscription'); + + return (await ui.showQuickPick(subscriptionPicks, placeHolder)).data; + } +} + +async function promptForDocumentDB(ui: IUserInterface, functionAppPath: string, credentials: ServiceClientCredentials, subscriptionId: string, setting: ConfigSetting): Promise { + const client: CosmosDBManagementClient = new CosmosDBManagementClient(credentials, subscriptionId); + const dbAccount: DatabaseAccount = await showResourceQuickPick(ui, setting, client.databaseAccounts.list()); + if (dbAccount.id && dbAccount.name) { + const resourceGroup: string = azUtil.getResourceGroupFromId(dbAccount.id); + const keys: DatabaseAccountListKeysResult = await client.databaseAccounts.listKeys(resourceGroup, dbAccount.name); + const appSettingName: string = `${dbAccount.name}_${ResourceType.DocumentDB.toUpperCase()}`; + await setLocalFunctionAppSetting(functionAppPath, appSettingName, `AccountEndpoint=${dbAccount.documentEndpoint};AccountKey=${keys.primaryMasterKey};`); + + return appSettingName; + } else { + throw new Error(localize('azFunc.InvalidCosmosDBAccount', 'Invalid Cosmos DB Account')); + } +} + +async function promptForBooleanSetting(ui: IUserInterface, setting: ConfigSetting): Promise { + const picks: Pick[] = [new Pick('true'), new Pick('false')]; + + return (await ui.showQuickPick(picks, setting.label, false)).label; +} + +async function promptForStringSetting(ui: IUserInterface, setting: ConfigSetting, defaultValue?: string): Promise { + const prompt: string = localize('azFunc.stringSettingPrompt', 'Provide a \'{0}\'', setting.label); + defaultValue = defaultValue ? defaultValue : setting.defaultValue; + + return await ui.showInputBox(setting.label, prompt, false, (s: string) => setting.validateSetting(s), defaultValue); +} + +interface IBaseResourceWithName extends BaseResource { + name?: string; +} + +async function showResourceQuickPick(ui: IUserInterface, setting: ConfigSetting, resourcesTask: Promise): Promise { + const picksTask: Promise[]> = resourcesTask.then((resources: T[]) => { + return []>(resources + .map((br: T) => br.name ? new PickWithData(br, br.name) : undefined) + .filter((p: PickWithData | undefined) => p)); + }); + const prompt: string = localize('azFunc.resourcePrompt', 'Select a \'{0}\'', setting.label); + + return (await ui.showQuickPick(picksTask, prompt)).data; +} + +interface ILocalSettings { + Values: { [key: string]: string }; +} + +async function setLocalFunctionAppSetting(functionAppPath: string, key: string, value: string): Promise { + const localSettingsPath: string = path.join(functionAppPath, 'local.settings.json'); + const localSettings: ILocalSettings = JSON.parse(await fsUtil.readFromFile(localSettingsPath)); + if (localSettings.Values[key]) { + const message: string = localize('azFunc.SettingAlreadyExists', 'Local app setting \'{0}\' already exists. Overwrite?', key); + const yes: string = localize('azFunc.yes', 'Yes'); + if (await vscode.window.showWarningMessage(message, yes) !== yes) { + return; + } + } + + localSettings.Values[key] = value; + await fsUtil.writeJsonToFile(localSettingsPath, localSettings); +} + +export async function createFunction( + outputChannel: vscode.OutputChannel, + azureAccount: AzureAccount, + templateData: TemplateData, + ui: IUserInterface = new VSCodeUI()): Promise { + + const folderPlaceholder: string = localize('azFunc.selectFunctionAppFolderExisting', 'Select the folder containing your function app'); + const functionAppPath: string = await workspaceUtil.selectWorkspaceFolder(ui, folderPlaceholder); + await validateIsFunctionApp(outputChannel, functionAppPath); + + const templatePicks: PickWithData