Retrieve templates from functions portal instead of cli (#32)

The main benefits:
1. We get the full list of templates supported in the portal
1. We can prompt the user for input parameters
1. We reduce reliance on the func cli

I also refactored uiUtil into an interface so that I could more easily create unit tests
This commit is contained in:
Eric Jizba 2017-10-30 17:02:20 -07:00 коммит произвёл GitHub
Родитель 2223cbf25a
Коммит 38d21f6148
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
28 изменённых файлов: 1214 добавлений и 147 удалений

Просмотреть файл

@ -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"

Просмотреть файл

@ -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<NodeBase> {
private onDidChangeTreeDataEmitter: EventEmitter<NodeBase> = new EventEmitter<NodeBase>();
private azureAccount: AzureAccount;
private rootNodes: NodeBase[] = [];
private _onDidChangeTreeDataEmitter: EventEmitter<NodeBase> = new EventEmitter<NodeBase>();
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<NodeBase> {
return this.onDidChangeTreeDataEmitter.event;
return this._onDidChangeTreeDataEmitter.event;
}
public getTreeItem(node: NodeBase): TreeItem {
@ -31,40 +34,40 @@ export class AzureFunctionsExplorer implements TreeDataProvider<NodeBase> {
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<NodeBase> {
let childType: string | undefined = 'Subscription';
let quickPicksTask: Promise<uiUtil.PickWithData<NodeBase>[]> = Promise.resolve(this.rootNodes.map((c: NodeBase) => new uiUtil.PickWithData<NodeBase>(c, c.label)));
let quickPicksTask: Promise<PickWithData<NodeBase>[]> = Promise.resolve(this._rootNodes.map((c: NodeBase) => new PickWithData<NodeBase>(c, c.label)));
while (childType) {
const pick: uiUtil.PickWithData<NodeBase> = await uiUtil.showQuickPick<NodeBase>(quickPicksTask, localize('azFunc.selectNode', 'Select a {0}', childType));
const pick: PickWithData<NodeBase> = await this._ui.showQuickPick<NodeBase>(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<NodeBase>[] => {
return nodes.map((c: NodeBase) => new uiUtil.PickWithData<NodeBase>(c, c.label));
quickPicksTask = node.getChildren(false).then((nodes: NodeBase[]): PickWithData<NodeBase>[] => {
return nodes.map((c: NodeBase) => new PickWithData<NodeBase>(c, c.label));
});
}

32
src/IUserInterface.ts Normal file
Просмотреть файл

@ -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<T>(items: PickWithData<T>[] | Thenable<PickWithData<T>[]>, placeHolder: string, ignoreFocusOut?: boolean): Promise<PickWithData<T>>;
showQuickPick(items: Pick[] | Thenable<Pick[]>, placeHolder: string, ignoreFocusOut?: boolean): Promise<Pick>;
showInputBox(placeHolder: string, prompt: string, ignoreFocusOut?: boolean, validateInput?: (s: string) => string | undefined | null, value?: string): Promise<string>;
showFolderDialog(): Promise<string>;
}
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<T> extends Pick {
public readonly data: T;
constructor(data: T, label: string, description?: string) {
super(label, description);
this.data = data;
}
}

62
src/VSCodeUI.ts Normal file
Просмотреть файл

@ -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<T>(items: PickWithData<T>[] | Thenable<PickWithData<T>[]>, placeHolder: string, ignoreFocusOut?: boolean): Promise<PickWithData<T>>;
public async showQuickPick(items: Pick[] | Thenable<Pick[]>, placeHolder: string, ignoreFocusOut?: boolean): Promise<Pick>;
public async showQuickPick(items: vscode.QuickPickItem[] | Thenable<vscode.QuickPickItem[]>, placeHolder: string, ignoreFocusOut: boolean = false): Promise<vscode.QuickPickItem> {
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<string> {
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<string> {
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;
}
}
}

Просмотреть файл

@ -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<void> {
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<void> {
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<string> {
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<string> {
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<AzureResourceFilter | undefined> {
if (azureAccount.filters.length === 0) {
return undefined;
} else if (azureAccount.filters.length === 1) {
return azureAccount.filters[0];
} else {
const subscriptionPicks: PickWithData<AzureResourceFilter>[] = [];
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<AzureResourceFilter>(f, label, subscriptionId));
}
});
const placeHolder: string = localize('azFunc.selectSubscription', 'Select a Subscription');
return (await ui.showQuickPick<AzureResourceFilter>(subscriptionPicks, placeHolder)).data;
}
}
async function promptForDocumentDB(ui: IUserInterface, functionAppPath: string, credentials: ServiceClientCredentials, subscriptionId: string, setting: ConfigSetting): Promise<string> {
const client: CosmosDBManagementClient = new CosmosDBManagementClient(credentials, subscriptionId);
const dbAccount: DatabaseAccount = await showResourceQuickPick<DatabaseAccount>(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<string> {
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<string> {
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<T extends IBaseResourceWithName>(ui: IUserInterface, setting: ConfigSetting, resourcesTask: Promise<T[]>): Promise<T> {
const picksTask: Promise<PickWithData<T>[]> = resourcesTask.then((resources: T[]) => {
return <PickWithData<T>[]>(resources
.map((br: T) => br.name ? new PickWithData(br, br.name) : undefined)
.filter((p: PickWithData<T> | undefined) => p));
});
const prompt: string = localize('azFunc.resourcePrompt', 'Select a \'{0}\'', setting.label);
return (await ui.showQuickPick<T>(picksTask, prompt)).data;
}
interface ILocalSettings {
Values: { [key: string]: string };
}
async function setLocalFunctionAppSetting(functionAppPath: string, key: string, value: string): Promise<void> {
const localSettingsPath: string = path.join(functionAppPath, 'local.settings.json');
const localSettings: ILocalSettings = <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<void> {
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<Template>[] = (await templateData.getTemplates()).map((t: Template) => new PickWithData<Template>(t, t.name));
const templatePlaceHolder: string = localize('azFunc.selectFuncTemplate', 'Select a function template');
const template: Template = (await ui.showQuickPick<Template>(templatePicks, templatePlaceHolder)).data;
const name: string = await promptForFunctionName(ui, functionAppPath, template);
let showPrompts: boolean = true;
for (const settingName of template.userPromptedSettings) {
const setting: ConfigSetting | undefined = await templateData.getSetting(template.bindingType, settingName);
if (setting) {
try {
let settingValue: string | undefined;
const defaultValue: string | undefined = template.getSetting(settingName);
if (showPrompts) {
settingValue = await promptForSetting(ui, azureAccount, functionAppPath, setting, defaultValue);
} else {
settingValue = defaultValue;
}
template.setSetting(settingName, settingValue);
} catch (error) {
if (error instanceof UserCancelledError) {
const message: string = localize('azFunc.IncompleteFunction', 'Function \'{0}\' was created, but you must finish specifying settings in \'function.json\'.', name);
vscode.window.showWarningMessage(message);
showPrompts = false;
} else {
throw error;
}
}
}
}
const functionPath: string = path.join(functionAppPath, name);
await template.writeTemplateFiles(functionPath);
const newFileUri: vscode.Uri = vscode.Uri.file(path.join(functionPath, 'index.js'));
vscode.window.showTextDocument(await vscode.workspace.openTextDocument(newFileUri));
}

Просмотреть файл

@ -7,13 +7,15 @@ import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import * as FunctionsCli from '../functions-cli';
import { IUserInterface } from '../IUserInterface';
import { localize } from '../localize';
import * as TemplateFiles from '../template-files';
import * as fsUtil from '../utils/fs';
import * as workspaceUtil from '../utils/workspace';
import { VSCodeUI } from '../VSCodeUI';
export async function createNewProject(outputChannel: vscode.OutputChannel): Promise<void> {
const functionAppPath: string = await workspaceUtil.selectWorkspaceFolder(localize('azFunc.selectFunctionAppFolderNew', 'Select the folder that will contain your function app'));
export async function createNewProject(outputChannel: vscode.OutputChannel, ui: IUserInterface = new VSCodeUI()): Promise<void> {
const functionAppPath: string = await workspaceUtil.selectWorkspaceFolder(ui, localize('azFunc.selectFunctionAppFolderNew', 'Select the folder that will contain your function app'));
const tasksJsonPath: string = path.join(functionAppPath, '.vscode', 'tasks.json');
const tasksJsonExists: boolean = fs.existsSync(tasksJsonPath);
@ -23,8 +25,8 @@ export async function createNewProject(outputChannel: vscode.OutputChannel): Pro
await FunctionsCli.createNewProject(outputChannel, functionAppPath);
if (!tasksJsonExists && !launchJsonExists) {
await fsUtil.writeToFile(tasksJsonPath, TemplateFiles.getTasksJson());
await fsUtil.writeToFile(launchJsonPath, TemplateFiles.getLaunchJson());
await fsUtil.writeJsonToFile(tasksJsonPath, TemplateFiles.tasksJson);
await fsUtil.writeJsonToFile(launchJsonPath, TemplateFiles.launchJson);
}
if (!workspaceUtil.isFolderOpenInWorkspace(functionAppPath)) {

Просмотреть файл

@ -7,12 +7,14 @@
import WebSiteManagementClient = require('azure-arm-website');
import * as vscode from 'vscode';
import { AzureFunctionsExplorer } from '../AzureFunctionsExplorer';
import { IUserInterface } from '../IUserInterface';
import { localize } from '../localize';
import { FunctionAppNode } from '../nodes/FunctionAppNode';
import * as workspaceUtil from '../utils/workspace';
import { VSCodeUI } from '../VSCodeUI';
export async function deployZip(explorer: AzureFunctionsExplorer, outputChannel: vscode.OutputChannel, uri?: vscode.Uri, node?: FunctionAppNode): Promise<void> {
const folderPath: string = uri ? uri.fsPath : await workspaceUtil.selectWorkspaceFolder(localize('azFunc.selectZipDeployFolder', 'Select the folder to zip and deploy'));
export async function deployZip(explorer: AzureFunctionsExplorer, outputChannel: vscode.OutputChannel, uri?: vscode.Uri, node?: FunctionAppNode, ui: IUserInterface = new VSCodeUI()): Promise<void> {
const folderPath: string = uri ? uri.fsPath : await workspaceUtil.selectWorkspaceFolder(ui, localize('azFunc.selectZipDeployFolder', 'Select the folder to zip and deploy'));
if (!node) {
node = <FunctionAppNode>(await explorer.showNodePicker(FunctionAppNode.contextValue));

Просмотреть файл

@ -21,6 +21,7 @@ import * as errors from './errors';
import { localize } from './localize';
import { FunctionAppNode } from './nodes/FunctionAppNode';
import { NodeBase } from './nodes/NodeBase';
import { TemplateData } from './templates/TemplateData';
let reporter: TelemetryReporter | undefined;
@ -47,9 +48,11 @@ export function activate(context: vscode.ExtensionContext): void {
context.subscriptions.push(azureAccount.onFiltersChanged(() => explorer.refresh()));
context.subscriptions.push(azureAccount.onStatusChanged(() => explorer.refresh()));
const templateData: TemplateData = new TemplateData(context.globalState);
initCommand<NodeBase>(context, 'azureFunctions.refresh', (node?: NodeBase) => explorer.refresh(node));
initCommand<NodeBase>(context, 'azureFunctions.openInPortal', async (node?: NodeBase) => await openInPortal(explorer, node));
initAsyncCommand<NodeBase>(context, 'azureFunctions.createFunction', async () => await createFunction(outputChannel));
initAsyncCommand<NodeBase>(context, 'azureFunctions.createFunction', async () => await createFunction(outputChannel, azureAccount, templateData));
initAsyncCommand<NodeBase>(context, 'azureFunctions.createNewProject', async () => await createNewProject(outputChannel));
initAsyncCommand<NodeBase>(context, 'azureFunctions.startFunctionApp', async (node?: FunctionAppNode) => await startFunctionApp(explorer, node));
initAsyncCommand<NodeBase>(context, 'azureFunctions.stopFunctionApp', async (node?: FunctionAppNode) => await stopFunctionApp(explorer, node));

Просмотреть файл

@ -7,10 +7,7 @@ import * as cp from 'child_process';
import * as vscode from 'vscode';
import { localize } from './localize';
export async function createFunction(outputChannel: vscode.OutputChannel, workingDirectory: string, templateName: string, name: string): Promise<void> {
await executeCommand(outputChannel, workingDirectory, 'new', '--language', 'JavaScript', '--template', templateName, '--name', name);
}
// tslint:disable-next-line:export-name
export async function createNewProject(outputChannel: vscode.OutputChannel, workingDirectory: string): Promise<void> {
await executeCommand(outputChannel, workingDirectory, 'init');
}

Просмотреть файл

@ -6,7 +6,7 @@
import { localize } from './localize';
const taskId: string = 'launchFunctionApp';
const tasksJson: {} = {
export const tasksJson: {} = {
version: '2.0.0',
tasks: [
{
@ -40,7 +40,7 @@ const tasksJson: {} = {
]
};
const launchJson: {} = {
export const launchJson: {} = {
version: '0.2.0',
configurations: [
{
@ -53,15 +53,3 @@ const launchJson: {} = {
}
]
};
function stringifyJSON(data: {}): string {
return JSON.stringify(data, null, ' ');
}
export function getTasksJson(): string {
return stringifyJSON(tasksJson);
}
export function getLaunchJson(): string {
return stringifyJSON(launchJson);
}

22
src/templates/Config.ts Normal file
Просмотреть файл

@ -0,0 +1,22 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ConfigBinding } from './ConfigBinding';
import { ConfigVariables } from './ConfigVariables';
import { Resources } from './Resources';
interface IConfig {
variables: { [name: string]: string };
bindings: object[];
}
export class Config {
public bindings: ConfigBinding[];
constructor(data: object, resources: Resources) {
const config: IConfig = <IConfig>data;
const variables: ConfigVariables = new ConfigVariables(config.variables, resources);
this.bindings = config.bindings.map((b: object) => new ConfigBinding(variables, b));
}
}

Просмотреть файл

@ -0,0 +1,23 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ConfigSetting } from './ConfigSetting';
import { ConfigVariables } from './ConfigVariables';
interface IBinding {
// tslint:disable-next-line:no-reserved-keywords
type: string;
settings: object[];
}
export class ConfigBinding {
public bindingType: string;
public settings: ConfigSetting[];
constructor(variables: ConfigVariables, data: object) {
const binding: IBinding = <IBinding>data;
this.bindingType = binding.type;
this.settings = binding.settings.map((s: object) => new ConfigSetting(variables, s));
}
}

Просмотреть файл

@ -0,0 +1,70 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ConfigValidator } from './ConfigValidator';
import { ConfigVariables } from './ConfigVariables';
export enum ValueType {
string = 'string',
boolean = 'boolean',
enum = 'enum',
checkBoxList = 'checkBoxList'
}
export enum ResourceType {
DocumentDB = 'DocumentDB'
}
interface IBindingSetting {
name: string;
value: ValueType;
label: string;
defaultValue?: string;
required: boolean;
resource?: ResourceType;
validators?: object[];
}
export class ConfigSetting {
private _setting: IBindingSetting;
private _variables: ConfigVariables;
constructor(variables: ConfigVariables, data: object) {
this._variables = variables;
this._setting = <IBindingSetting>data;
}
public get resourceType(): ResourceType | undefined {
return this._setting.resource;
}
public get valueType(): ValueType | undefined {
return this._setting.value;
}
public get defaultValue(): string | undefined {
return this._setting.defaultValue ? this._variables.getValue(this._setting.defaultValue) : undefined;
}
public get label(): string {
return this._variables.getValue(this._setting.label);
}
public get name(): string {
return this._variables.getValue(this._setting.name);
}
public validateSetting(value: string | undefined): string | undefined {
if (this._setting.validators) {
const validators: ConfigValidator[] = this._setting.validators.map((v: object) => new ConfigValidator(this._variables, v));
for (const validator of validators) {
if (!value || value.match(validator.expression) === null) {
return validator.errorText;
}
}
}
return undefined;
}
}

Просмотреть файл

@ -0,0 +1,28 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ConfigVariables } from './ConfigVariables';
interface IConfigValidator {
expression: string;
errorText: string;
}
export class ConfigValidator {
private _validator: IConfigValidator;
private _variables: ConfigVariables;
constructor(variables: ConfigVariables, data: object) {
this._variables = variables;
this._validator = <IConfigValidator>data;
}
public get expression(): string {
return this._validator.expression;
}
public get errorText(): string | undefined {
return this._variables.getValue(this._validator.errorText);
}
}

Просмотреть файл

@ -0,0 +1,22 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Resources } from './Resources';
export class ConfigVariables {
private _variables: { [name: string]: string };
private _resources: Resources;
constructor(variables: { [name: string]: string }, resources: Resources) {
this._variables = variables;
this._resources = resources;
}
public getValue(data: string): string {
const matches: RegExpMatchArray | null = data.match(/\[variables\(\'(.*)\'\)\]/);
data = matches !== null ? this._variables[matches[1]] : data;
return this._resources.getValue(data);
}
}

115
src/templates/README.md Normal file
Просмотреть файл

@ -0,0 +1,115 @@
# Azure Function Templates
The code in this folder is used to parse and model the [function templates](https://github.com/Azure/azure-webjobs-sdk-templates) provided by the [Azure Functions portal](https://functions.azure.com/signin). The portal provides template data in three parts: templates.json, bindingconfig.json, and resources.json. See below for an example of the schema for the TimerTrigger template
## Templates.json
[https://functions.azure.com/api/templates?runtime=~2](https://functions.azure.com/api/templates?runtime=~2)
```json
[
{
"id": "TimerTrigger-JavaScript",
"function": {
"disabled": false,
"bindings": [
{
"name": "myTimer",
"type": "timerTrigger",
"direction": "in",
"schedule": "0 */5 * * * *"
}
]
},
"metadata": {
"defaultFunctionName": "TimerTriggerJS",
"description": "$TimerTriggerNodeJS_description",
"name": "TimerTrigger",
"language": "JavaScript",
"category": [
"$temp_category_core",
"$temp_category_dataProcessing"
],
"enabledInTryMode": true,
"userPrompt": [
"schedule"
]
},
"files": {
"index.js": "module.exports = function (context, myTimer) {\n var timeStamp = new Date().toISOString();\n \n if(myTimer.isPastDue)\n {\n context.log('JavaScript is running late!');\n }\n context.log('JavaScript timer trigger function ran!', timeStamp); \n \n context.done();\n};",
"readme.md": "# TimerTrigger - JavaScript\n\nThe `TimerTrigger` makes it incredibly easy to have your functions executed on a schedule. This sample demonstrates a simple use case of calling your function every 5 minutes.\n\n## How it works\n\nFor a `TimerTrigger` to work, you provide a schedule in the form of a [cron expression](https://en.wikipedia.org/wiki/Cron#CRON_expression)(See the link for full details). A cron expression is a string with 6 separate expressions which represent a given schedule via patterns. The pattern we use to represent every 5 minutes is `0 */5 * * * *`. This, in plain text, means: \"When seconds is equal to 0, minutes is divisible by 5, for any hour, day of the month, month, day of the week, or year\".\n\n## Learn more\n\n<TODO> Documentation",
"sample.dat": ""
},
"runtime": "default"
}
]
```
## BindingConfig.json
[https://functions.azure.com/api/bindingconfig?runtime=~2](https://functions.azure.com/api/bindingconfig?runtime=~2)
```json
{
"$schema": "<TBD>",
"contentVersion": "2016-03-04-alpha",
"variables": {
"parameterName": "$variables_parameterName"
},
"bindings": [
{
"type": "timerTrigger",
"displayName": "$timerTrigger_displayName",
"direction": "trigger",
"enabledInTryMode": true,
"documentation": "## Settings for timer trigger\n\nThe settings provide a schedule expression. For example, the following schedule runs the function every minute:\n\n - `schedule`: Cron tab expression which defines schedule \n - `name`: The variable name used in function code for the TimerTrigger. \n - `type`: must be *timerTrigger*\n - `direction`: must be *in*\n\nThe timer trigger handles multi-instance scale-out automatically: only a single instance of a particular timer function will be running across all instances.\n\n## Format of schedule expression\n\nThe schedule expression is a [CRON expression](http://en.wikipedia.org/wiki/Cron#CRON_expression) that includes 6 fields: `{second} {minute} {hour} {day} {month} {day of the week}`. \n\nNote that many of the cron expressions you find online omit the {second} field, so if you copy from one of those you'll have to adjust for the extra field. \n\nHere are some other schedule expression examples:\n\nTo trigger once every 5 minutes:\n\n```json\n\"schedule\": \"0 */5 * * * *\"\n```\n\nTo trigger once at the top of every hour:\n\n```json\n\"schedule\": \"0 0 * * * *\",\n```\n\nTo trigger once every two hours:\n\n```json\n\"schedule\": \"0 0 */2 * * *\",\n```\n\nTo trigger once every hour from 9 AM to 5 PM:\n\n```json\n\"schedule\": \"0 0 9-17 * * *\",\n```\n\nTo trigger At 9:30 AM every day:\n\n```json\n\"schedule\": \"0 30 9 * * *\",\n```\n\nTo trigger At 9:30 AM every weekday:\n\n```json\n\"schedule\": \"0 30 9 * * 1-5\",\n```\n\n## Timer trigger C# code example\n\nThis C# code example writes a single log each time the function is triggered.\n\n```csharp\npublic static void Run(TimerInfo myTimer, TraceWriter log)\n{\n log.Info($\"C# Timer trigger function executed at: {DateTime.Now}\"); \n}\n```\n\n## Timer trigger JavaScript example\n\n```JavaScript\nmodule.exports = function(context, myTimer) {\n if(myTimer.isPastDue)\n {\n context.log('JavaScript is running late!');\n }\n context.log(\"Timer last triggered at \" + myTimer.last);\n context.log(\"Timer triggered at \" + myTimer.next);\n \n context.done();\n}\n```",
"settings": [
{
"name": "name",
"value": "string",
"defaultValue": "myTimer",
"required": true,
"label": "$timerTrigger_name_label",
"help": "$timerTrigger_name_help",
"validators": [
{
"expression": "^[a-zA-Z][a-zA-Z0-9]{0,127}$",
"errorText": "[variables('parameterName')]"
}
]
},
{
"name": "schedule",
"value": "string",
"defaultValue": "0 * * * * *",
"required": true,
"label": "$timerTrigger_schedule_label",
"help": "$timerTrigger_schedule_help",
"validators": [
{
"expression": "^(\\*|((([1-5]\\d)|\\d)(\\-(([1-5]\\d)|\\d)(\\/\\d+)?)?)(,((([1-5]\\d)|\\d)(\\-(([1-5]\\d)|\\d)(\\/\\d+)?)?))*)(\\/\\d+)? (\\*|((([1-5]\\d)|\\d)(\\-(([1-5]\\d)|\\d)(\\/\\d+)?)?)(,((([1-5]\\d)|\\d)(\\-(([1-5]\\d)|\\d)(\\/\\d+)?)?))*)(\\/\\d+)? (\\*|(((1\\d)|(2[0-3])|\\d)(\\-((1\\d)|(2[0-3])|\\d)(\\/\\d+)?)?)(,(((1\\d)|(2[0-3])|\\d)(\\-((1\\d)|(2[0-3])|\\d)(\\/\\d+)?)?))*)(\\/\\d+)? (\\*|((([1-2]\\d)|(3[0-1])|[1-9])(\\-(([1-2]\\d)|(3[0-1])|[1-9])(\\/\\d+)?)?)(,((([1-2]\\d)|(3[0-1])|[1-9])(\\-(([1-2]\\d)|(3[0-1])|[1-9])(\\/\\d+)?)?))*)(\\/\\d+)? (\\*|(([A-Za-z]+|(1[0-2])|[1-9])(\\-([A-Za-z]+|(1[0-2])|[1-9])(\\/\\d+)?)?)(,(([A-Za-z]+|(1[0-2])|[1-9])(\\-([A-Za-z]+|(1[0-2])|[1-9])(\\/\\d+)?)?))*)(\\/\\d+)? (\\*|(([A-Za-z]+|[0-6])(\\-([A-Za-z]+|[0-6])(\\/\\d+)?)?)(,(([A-Za-z]+|[0-6])(\\-([A-Za-z]+|[0-6])(\\/\\d+)?)?))*)(\\/\\d+)?$",
"errorText": "$timerTrigger_schedule_errorText"
}
]
}
]
}
]
}
```
## Resources.json
[https://functions.azure.com/api/resources?runtime=~2&name=en-us](https://functions.azure.com/api/resources?runtime=~2&name=en-us)
```json
{
"lang": {},
"en": {
"temp_category_core": "Core",
"temp_category_dataProcessing": "Data Processing",
"timerTrigger_displayName": "Timer",
"timerTrigger_name_help": "The name used to identify this trigger in your code",
"timerTrigger_name_label": "Timestamp parameter name",
"timerTrigger_schedule_help": "Enter a cron expression of the format '{second} {minute} {hour} {day} {month} {day of week}' to specify the schedule. See documentation below for examples.",
"timerTrigger_schedule_label": "Schedule",
"TimerTriggerNodeJS_description": "A JavaScript function that will be run on a specified schedule",
"timerTrigger_schedule_errorText": "Invalid Cron Expression. Please consult the <a target='_blank' href='https://azure.microsoft.com/en-us/documentation/articles/functions-bindings-timer/'>documentation</a> to learn more.",
"variables_parameterName": "The parameter name must be an alphanumeric string of any number of characters and cannot start with a number."
}
}
```

Просмотреть файл

@ -0,0 +1,21 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
interface IResources {
en: { [key: string]: string };
}
export class Resources {
private _resources: IResources;
constructor(data: object) {
this._resources = <IResources>data;
}
public getValue(data: string): string {
const matches: RegExpMatchArray | null = data.match(/\$(.*)/);
return matches !== null ? this._resources.en[matches[1]] : data;
}
}

92
src/templates/Template.ts Normal file
Просмотреть файл

@ -0,0 +1,92 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
import * as fsUtil from '../utils/fs';
import { Resources } from './Resources';
interface ITemplate {
id: string;
// tslint:disable-next-line:no-reserved-keywords
function: ITemplateFunction;
metadata: ITemplateMetadata;
files: { [filename: string]: string };
}
interface ITemplateFunction {
disabled: boolean;
bindings: { [propertyName: string]: string }[];
}
interface ITemplateMetadata {
defaultFunctionName: string;
name: string;
language: TemplateLanguage;
userPrompt?: string[];
category: TemplateCategory[];
}
export enum TemplateLanguage {
JavaScript = 'JavaScript'
}
export enum TemplateCategory {
Core = '$temp_category_core'
}
export class Template {
private _template: ITemplate;
private _resources: Resources;
constructor(template: object, resources: Resources) {
this._template = <ITemplate>template;
this._resources = resources;
}
public get name(): string {
return this._resources.getValue(this._template.metadata.name);
}
public get defaultFunctionName(): string {
return this._template.metadata.defaultFunctionName;
}
public get language(): TemplateLanguage {
return this._template.metadata.language;
}
public isCategory(category: TemplateCategory): boolean {
return this._template.metadata.category.find((c: TemplateCategory) => c === category) !== undefined;
}
public get bindingType(): string {
// The first binding is the 'input' binding that matters for userInput
return this._template.function.bindings[0].type;
}
public get userPromptedSettings(): string[] {
return this._template.metadata.userPrompt ? this._template.metadata.userPrompt : [];
}
public getSetting(name: string): string | undefined {
// The first binding is the 'input' binding that matters for userInput
return this._template.function.bindings[0][name];
}
public setSetting(name: string, value?: string): void {
// The first binding is the 'input' binding that matters for userInput
this._template.function.bindings[0][name] = value ? value : '';
}
public async writeTemplateFiles(functionPath: string): Promise<void> {
await fsUtil.makeFolder(functionPath);
const tasks: Promise<void>[] = Object.keys(this._template.files).map(async (fileName: string) => {
await fsUtil.writeToFile(path.join(functionPath, fileName), this._template.files[fileName]);
});
tasks.push(fsUtil.writeJsonToFile(path.join(functionPath, 'function.json'), this._template.function));
await Promise.all(tasks);
}
}

Просмотреть файл

@ -0,0 +1,105 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// tslint:disable-next-line:no-require-imports
import request = require('request-promise');
import * as vscode from 'vscode';
import { localize } from '../localize';
import { Config } from './Config';
import { ConfigBinding } from './ConfigBinding';
import { ConfigSetting } from './ConfigSetting';
import { Resources } from './Resources';
import { Template, TemplateCategory, TemplateLanguage } from './Template';
/**
* Main container for all template data retrieved from the Azure Functions Portal. See README.md for more info and example of the schema.
* We cache the template data retrieved from the portal so that the user can create functions offline.
*/
export class TemplateData {
private readonly _templateInitError: Error = new Error(localize('azFunc.TemplateInitError', 'Failed to retrieve templates from the Azure Functions Portal.'));
private readonly _templatesKey: string = 'FunctionTemplates';
private readonly _configKey: string = 'FunctionTemplateConfig';
private readonly _resourcesKey: string = 'FunctionTemplateResources';
private readonly _refreshTask: Promise<void>;
private _templates: Template[] | undefined;
private _config: Config | undefined;
constructor(globalState?: vscode.Memento) {
if (globalState) {
const cachedResources: object | undefined = globalState.get<object>(this._resourcesKey);
const cachedTemplates: object[] | undefined = globalState.get<object[]>(this._templatesKey);
const cachedConfig: object | undefined = globalState.get<object>(this._configKey);
if (cachedResources && cachedTemplates && cachedConfig) {
const resources: Resources = new Resources(cachedResources);
this._templates = cachedTemplates.map((t: object) => new Template(t, resources));
this._config = new Config(cachedConfig, resources);
}
}
this._refreshTask = this.refreshTemplates(globalState);
}
public async getTemplates(): Promise<Template[]> {
if (this._templates === undefined) {
await this._refreshTask;
if (this._templates === undefined) {
throw this._templateInitError;
}
}
return this._templates.filter((t: Template) => {
return t.language === TemplateLanguage.JavaScript && t.isCategory(TemplateCategory.Core);
});
}
public async getSetting(bindingType: string, settingName: string): Promise<ConfigSetting | undefined> {
if (this._config === undefined) {
await this._refreshTask;
if (this._config === undefined) {
throw this._templateInitError;
}
}
const binding: ConfigBinding | undefined = this._config.bindings.find((b: ConfigBinding) => b.bindingType === bindingType);
if (binding) {
return binding.settings.find((bs: ConfigSetting) => bs.name === settingName);
} else {
return undefined;
}
}
private async refreshTemplates(globalState?: vscode.Memento): Promise<void> {
try {
const rawResources: object = await this.requestFunctionPortal<object>('resources', 'name=en-us');
const rawTemplates: object[] = await this.requestFunctionPortal<object[]>('templates');
const rawConfig: object = await this.requestFunctionPortal<object>('bindingconfig');
const resources: Resources = new Resources(rawResources);
this._templates = rawTemplates.map((t: object) => new Template(t, resources));
this._config = new Config(rawConfig, resources);
if (globalState) {
globalState.update(this._templatesKey, rawTemplates);
globalState.update(this._configKey, rawConfig);
globalState.update(this._resourcesKey, rawResources);
}
} catch (error) {
// ignore errors - use cached version of templates instead
}
}
private async requestFunctionPortal<T>(subPath: string, param?: string): Promise<T> {
const options: request.OptionsWithUri = {
method: 'GET',
uri: `https://functions.azure.com/api/${subPath}?runtime=~2$&${param}`,
headers: {
'User-Agent': 'Mozilla/5.0' // Required otherwise we get Unauthorized
}
};
return <T>(JSON.parse(await <Thenable<string>>request(options).promise()));
}
}

17
src/utils/azure.ts Normal file
Просмотреть файл

@ -0,0 +1,17 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { localize } from '../localize';
// tslint:disable-next-line:export-name
export function getResourceGroupFromId(id: string): string {
const matches: RegExpMatchArray | null = id.match(/\/subscriptions\/(.*)\/resourceGroups\/(.*)\/providers\/(.*)\/(.*)/);
if (matches === null || matches.length < 3) {
throw new Error(localize('azFunc.InvalidResourceId', 'Invalid Azure Resource Id'));
}
return matches[2];
}

Просмотреть файл

@ -4,11 +4,17 @@
*--------------------------------------------------------------------------------------------*/
import * as fs from 'fs';
import * as path from 'path';
// tslint:disable-next-line:no-require-imports
import rimraf = require('rimraf');
// tslint:disable-next-line:export-name
export async function writeToFile(path: string, data: string): Promise<void> {
export async function writeJsonToFile(fsPath: string, data: object): Promise<void> {
await writeToFile(fsPath, JSON.stringify(data, undefined, 2));
}
export async function writeToFile(fsPath: string, data: string): Promise<void> {
await new Promise((resolve: () => void, reject: (e: Error) => void): void => {
fs.writeFile(path, data, (error?: Error) => {
fs.writeFile(fsPath, data, (error?: Error) => {
if (error) {
reject(error);
} else {
@ -17,3 +23,73 @@ export async function writeToFile(path: string, data: string): Promise<void> {
});
});
}
export async function readFromFile(fsPath: string): Promise<string> {
return await new Promise((resolve: (data: string) => void, reject: (e: Error) => void): void => {
fs.readFile(fsPath, (error: Error | undefined, data: Buffer) => {
if (error) {
reject(error);
} else {
resolve(data.toString());
}
});
});
}
export async function makeFolder(fsPath: string): Promise<void> {
if (!(await fsPathExists(fsPath))) {
await new Promise((resolve: () => void, reject: (err: Error) => void): void => {
fs.mkdir(fsPath, (err?: Error) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
}
export async function deleteFolderAndContents(fsPath: string): Promise<void> {
await new Promise((resolve: () => void, reject: (err: Error) => void): void => {
rimraf(fsPath, (err?: Error) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
export async function fsPathExists(fsPath: string): Promise<boolean> {
return await new Promise((resolve: (r: boolean) => void, reject: (e: Error) => void): void => {
fs.exists(fsPath, (result: boolean, error?: Error) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
}
export async function getUniqueFsPath(folderPath: string, defaultValue: string): Promise<string | undefined> {
let count: number = 0;
const maxCount: number = 1024;
while (count < maxCount) {
const fileName: string = defaultValue + (count === 0 ? '' : count.toString());
if (!(await fsPathExists(path.join(folderPath, fileName)))) {
return fileName;
}
count += 1;
}
return undefined;
}
export function randomName(): string {
// tslint:disable-next-line:insecure-random
return Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 10);
}

Просмотреть файл

@ -1,76 +0,0 @@
/*---------------------------------------------------------------------------------------------
* 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 { QuickPickItem } from 'vscode';
import * as errors from '../errors';
import { localize } from '../localize';
export async function showQuickPick<T>(items: PickWithData<T>[] | Thenable<PickWithData<T>[]>, placeHolder: string, ignoreFocusOut?: boolean): Promise<PickWithData<T>>;
export async function showQuickPick(items: Pick[] | Thenable<Pick[]>, placeHolder: string, ignoreFocusOut?: boolean): Promise<Pick>;
export async function showQuickPick(items: vscode.QuickPickItem[] | Thenable<vscode.QuickPickItem[]>, placeHolder: string, ignoreFocusOut: boolean = false): Promise<vscode.QuickPickItem> {
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;
}
}
export async function showInputBox(placeHolder: string, prompt: string, ignoreFocusOut: boolean = false, validateInput?: (s: string) => string | undefined | null): Promise<string> {
const options: vscode.InputBoxOptions = {
placeHolder: placeHolder,
prompt: prompt,
validateInput: validateInput,
ignoreFocusOut: ignoreFocusOut
};
const result: string | undefined = await vscode.window.showInputBox(options);
if (!result) {
throw new errors.UserCancelledError();
} else {
return result;
}
}
export async function showFolderDialog(): Promise<string> {
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;
}
}
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<T> extends Pick {
public readonly data: T;
constructor(data: T, label: string, description?: string) {
super(label, description);
this.data = data;
}
}

Просмотреть файл

@ -5,20 +5,20 @@
import * as path from 'path';
import * as vscode from 'vscode';
import { IUserInterface, PickWithData } from '../IUserInterface';
import { localize } from '../localize';
import * as uiUtil from './ui';
export async function selectWorkspaceFolder(placeholder: string): Promise<string> {
export async function selectWorkspaceFolder(ui: IUserInterface, placeholder: string): Promise<string> {
const browse: string = ':browse';
let folder: uiUtil.PickWithData<string> | undefined;
let folder: PickWithData<string> | undefined;
if (vscode.workspace.workspaceFolders) {
let folderPicks: uiUtil.PickWithData<string>[] = [new uiUtil.PickWithData(browse, localize('azFunc.browse', '$(file-directory) Browse...'))];
folderPicks = folderPicks.concat(vscode.workspace.workspaceFolders.map((f: vscode.WorkspaceFolder) => new uiUtil.PickWithData('', f.uri.fsPath)));
let folderPicks: PickWithData<string>[] = [new PickWithData(browse, localize('azFunc.browse', '$(file-directory) Browse...'))];
folderPicks = folderPicks.concat(vscode.workspace.workspaceFolders.map((f: vscode.WorkspaceFolder) => new PickWithData('', f.uri.fsPath)));
folder = await uiUtil.showQuickPick<string>(folderPicks, placeholder);
folder = await ui.showQuickPick<string>(folderPicks, placeholder);
}
return folder && folder.data !== browse ? folder.label : await uiUtil.showFolderDialog();
return folder && folder.data !== browse ? folder.label : await ui.showFolderDialog();
}
export function isFolderOpenInWorkspace(fsPath: string): boolean {

17
test/TestAzureAccount.ts Normal file
Просмотреть файл

@ -0,0 +1,17 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Event } from 'vscode';
import { AzureAccount, AzureLoginStatus, AzureResourceFilter, AzureSession } from '../src/azure-account.api';
export class TestAzureAccount implements AzureAccount {
public readonly status: AzureLoginStatus;
public readonly onStatusChanged: Event<AzureLoginStatus>;
public readonly sessions: AzureSession[] = [];
public readonly onSessionsChanged: Event<void>;
public readonly filters: AzureResourceFilter[] = [];
public readonly onFiltersChanged: Event<void>;
public async waitForLogin(): Promise<boolean> { return false; }
}

74
test/TestUI.ts Normal file
Просмотреть файл

@ -0,0 +1,74 @@
/*---------------------------------------------------------------------------------------------
* 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 { IUserInterface, Pick, PickWithData } from '../src/IUserInterface';
export class TestUI implements IUserInterface {
private _inputs: (string | undefined)[];
constructor(inputs: (string | undefined)[]) {
this._inputs = inputs;
}
public async showQuickPick<T>(items: PickWithData<T>[] | Thenable<PickWithData<T>[]>, placeHolder: string, ignoreFocusOut?: boolean): Promise<PickWithData<T>>;
public async showQuickPick(items: Pick[] | Thenable<Pick[]>, placeHolder: string, ignoreFocusOut?: boolean): Promise<Pick>;
public async showQuickPick(items: vscode.QuickPickItem[] | Thenable<vscode.QuickPickItem[]>, placeHolder: string, _ignoreFocusOut: boolean = false): Promise<vscode.QuickPickItem> {
if (this._inputs.length > 0) {
const input: string | undefined = this._inputs.shift();
const resolvedItems: vscode.QuickPickItem[] = await Promise.resolve(items);
if (resolvedItems.length === 0) {
throw new Error(`No quick pick items found. Placeholder: '${placeHolder}'`);
} else if (input) {
const resolvedItem: vscode.QuickPickItem | undefined = resolvedItems.find((qpi: vscode.QuickPickItem) => qpi.label === input);
if (resolvedItem) {
return resolvedItem;
} else {
throw new Error(`Did not find quick pick item matching '${input}'. Placeholder: '${placeHolder}'`);
}
} else {
// Use default value if input is undefined
return resolvedItems[0];
}
}
throw new Error(`Unexpected call to showQuickPick. Placeholder: '${placeHolder}'`);
}
public async showInputBox(placeHolder: string, prompt: string, _ignoreFocusOut: boolean = false, validateInput?: (s: string) => string | undefined | null, value?: string): Promise<string> {
if (this._inputs.length > 0) {
let result: string | undefined = this._inputs.shift();
if (!result) {
// Use default value if input is undefined
result = value;
}
if (result) {
if (validateInput) {
const msg: string | null | undefined = validateInput(result);
if (msg !== null && msg !== undefined) {
throw new Error(msg);
}
}
return result;
}
}
throw new Error(`Unexpected call to showInputBox. Placeholder: '${placeHolder}'. Prompt: '${prompt}'`);
}
public async showFolderDialog(): Promise<string> {
if (this._inputs.length > 0) {
const result: string | undefined = this._inputs.shift();
if (result) {
return result;
}
}
throw new Error('Unexpected call to showFolderDialog');
}
}

154
test/createFunction.test.ts Normal file
Просмотреть файл

@ -0,0 +1,154 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import * as os from 'os';
import * as path from 'path';
import * as vscode from 'vscode';
import { createFunction } from '../src/commands/createFunction';
import { TemplateData } from '../src/templates/TemplateData';
import * as fsUtil from '../src/utils/fs';
import { TestAzureAccount } from './TestAzureAccount';
import { TestUI } from './TestUI';
const templateData: TemplateData = new TemplateData();
const testFolder: string = path.join(os.tmpdir(), `azFunc.createFuncTests${fsUtil.randomName()}`);
const outputChannel: vscode.OutputChannel = vscode.window.createOutputChannel('Azure Functions Test');
suiteSetup(async () => {
await fsUtil.makeFolder(testFolder);
await fsUtil.makeFolder(path.join(testFolder, '.vscode'));
// Pretend to create the parent function app
await Promise.all([
fsUtil.writeToFile(path.join(testFolder, 'host.json'), ''),
fsUtil.writeToFile(path.join(testFolder, 'local.settings.json'), ''),
fsUtil.writeToFile(path.join(testFolder, '.vscode', 'launch.json'), '')
]);
});
suiteTeardown(async () => {
outputChannel.dispose();
await fsUtil.deleteFolderAndContents(testFolder);
});
suite('Create Function Tests', () => {
const blobTrigger: string = 'BlobTrigger';
test(blobTrigger, async () => {
await testCreateFunction(
blobTrigger,
'storageAccountConnection',
undefined // Use default path
);
});
const cosmosDBTrigger: string = 'CosmosDBTrigger';
test(cosmosDBTrigger, async () => {
await testCreateFunction(
cosmosDBTrigger,
'cosmosDBConnection',
'dbName',
'collectionName',
undefined, // Use default for 'create leases if doesn't exist'
undefined // Use default lease name
);
});
const eventHubTrigger: string = 'EventHubTrigger';
test(eventHubTrigger, async () => {
await testCreateFunction(
eventHubTrigger,
'eventHubConnection',
undefined, // Use default event hub name
undefined // Use default event hub consumer group
);
});
const genericWebhook: string = 'Generic Webhook';
test(genericWebhook, async () => {
await testCreateFunction(genericWebhook);
});
const gitHubWebhook: string = 'GitHub Webhook';
test(gitHubWebhook, async () => {
await testCreateFunction(gitHubWebhook);
});
const httpTrigger: string = 'HttpTrigger';
test(httpTrigger, async () => {
await testCreateFunction(
httpTrigger,
undefined // Use default Authorization level
);
});
const httpTriggerWithParameters: string = 'HttpTriggerWithParameters';
test(httpTriggerWithParameters, async () => {
await testCreateFunction(
httpTriggerWithParameters,
undefined // Use default Authorization level
);
});
const manualTrigger: string = 'ManualTrigger';
test(manualTrigger, async () => {
await testCreateFunction(manualTrigger);
});
const queueTrigger: string = 'QueueTrigger';
test(queueTrigger, async () => {
await testCreateFunction(
queueTrigger,
'storageAccountConnection',
undefined // Use default queue name
);
});
const serviceBusQueueTrigger: string = 'ServiceBusQueueTrigger';
test(serviceBusQueueTrigger, async () => {
await testCreateFunction(
serviceBusQueueTrigger,
'serviceBusConnection',
'accessRights',
undefined // Use default queue name
);
});
const serviceBusTopicTrigger: string = 'ServiceBusTopicTrigger';
test(serviceBusTopicTrigger, async () => {
await testCreateFunction(
serviceBusTopicTrigger,
'serviceBusConnection',
'accessRights',
undefined, // Use default topic name
undefined // Use default subscription name
);
});
const timerTrigger: string = 'TimerTrigger';
test(timerTrigger, async () => {
await testCreateFunction(
timerTrigger,
undefined // Use default schedule
);
});
});
async function testCreateFunction(funcName: string, ...inputs: (string | undefined)[]): Promise<void> {
// Setup common inputs
inputs.unshift(funcName); // Specify the function name
inputs.unshift(funcName); // Select the function template
inputs.unshift(testFolder); // Select the test func app folder
if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) {
inputs.unshift(undefined); // If the test environment has an open workspace, select the 'Browse...' option
}
const ui: TestUI = new TestUI(inputs);
await createFunction(outputChannel, new TestAzureAccount(), templateData, ui);
assert.equal(inputs.length, 0, 'Not all inputs were used.');
const functionPath: string = path.join(testFolder, funcName);
assert.equal(await fsUtil.fsPathExists(path.join(functionPath, 'index.js')), true, 'index.js does not exist');
assert.equal(await fsUtil.fsPathExists(path.join(functionPath, 'function.json')), true, 'function.json does not exist');
}

Просмотреть файл

@ -6,6 +6,8 @@ open source projects along with the license information below. We acknowledge an
are grateful to these developers for their contribution to open source.
1. opn (https://github.com/sindresorhus/opn)
1. rimraf (https://github.com/isaacs/rimraf)
1. request-promise (https://github.com/request/request-promise)
opn NOTICES BEGIN HERE
=============================
@ -34,3 +36,47 @@ THE SOFTWARE.
END OF opn NOTICES AND INFORMATION
==================================
rimraf NOTICES BEGIN HERE
=============================
The ISC License
Copyright (c) Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
END OF rimraf NOTICES AND INFORMATION
==================================
request-promise NOTICES BEGIN HERE
=============================
ISC License
Copyright (c) 2017, Nicolai Kamenzky, Ty Abonil, and contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
END OF request-promise NOTICES AND INFORMATION
==================================

Просмотреть файл

@ -196,7 +196,12 @@
],
"underscore-consistent-invocation": true,
"unified-signatures": true,
"variable-name": true,
"variable-name": [ // changed
true,
"ban-keywords",
"check-format",
"allow-leading-underscore"
],
"react-a11y-anchors": true,
"react-a11y-aria-unsupported-elements": true,
"react-a11y-event-has-role": true,