From 2d1ffeb47c3d83468b40e053df083b950084fbe5 Mon Sep 17 00:00:00 2001 From: Vasu Bhog Date: Mon, 14 Mar 2022 13:07:27 -0700 Subject: [PATCH] Refactor vscode-mssql sql bindings logic to sql bindings ext (#18725) * wip for refactor of mssql to sql-bindings * remove STS dependency * work to bring function over and setup with vscodeMsql APIs * copy typings from vscode-mssql --- extensions/sql-bindings/package.json | 19 +- extensions/sql-bindings/package.nls.json | 3 +- .../src/common/azureFunctionsUtils.ts | 241 ++++++++++++++++-- .../sql-bindings/src/common/constants.ts | 33 ++- extensions/sql-bindings/src/common/utils.ts | 93 +++++-- extensions/sql-bindings/src/extension.ts | 27 +- .../src/services/azureFunctionsService.ts | 125 +++++++++ extensions/sql-bindings/src/test/testUtils.ts | 4 + .../typings/vscode-azurefunctions.api.d.ts | 86 +++++++ .../src/typings/vscode-azuretools.api.d.ts | 23 ++ .../src/typings/vscode-mssql.d.ts | 13 + extensions/sql-bindings/yarn.lock | 41 +-- 12 files changed, 627 insertions(+), 81 deletions(-) create mode 100644 extensions/sql-bindings/src/services/azureFunctionsService.ts create mode 100644 extensions/sql-bindings/src/typings/vscode-azurefunctions.api.d.ts create mode 100644 extensions/sql-bindings/src/typings/vscode-azuretools.api.d.ts diff --git a/extensions/sql-bindings/package.json b/extensions/sql-bindings/package.json index 640b91280e..85c148ba53 100644 --- a/extensions/sql-bindings/package.json +++ b/extensions/sql-bindings/package.json @@ -13,7 +13,8 @@ "icon": "", "aiKey": "AIF-37eefaf0-8022-4671-a3fb-64752724682e", "activationEvents": [ - "onCommand:sqlBindings.addSqlBinding" + "onCommand:sqlBindings.addSqlBinding", + "onCommand:sqlBindings.createAzureFunction" ], "main": "./out/extension", "repository": { @@ -35,6 +36,12 @@ "command": "sqlBindings.addSqlBinding", "title": "%sqlBindings.addSqlBinding%", "category": "MS SQL" + }, + { + "command": "sqlBindings.createAzureFunction", + "title": "%sqlBindings.createAzureFunction%", + "category": "MS SQL", + "when": "view == objectExplorer && viewItem == Table" } ], "menus": { @@ -43,19 +50,25 @@ "command": "sqlBindings.addSqlBinding", "when": "editorLangId == csharp && !azdataAvailable && resourceScheme != untitled" } + ], + "view/item/context": [ + { + "command": "sqlBindings.createAzureFunction", + "when": "view == objectExplorer && viewItem == Table", + "group": "zAzure_Function@1" + } ] } }, "dependencies": { "@microsoft/ads-extension-telemetry": "^1.1.5", "fast-glob": "^3.2.7", - "fs-extra": "^5.0.0", "jsonc-parser": "^2.3.1", "promisify-child-process": "^3.1.1", "vscode-nls": "^4.1.2" }, "devDependencies": { - "@types/fs-extra": "^5.0.0", + "@types/node": "^14.14.16", "tslint": "^5.8.0", "should": "^13.2.1", "sinon": "^9.0.2", diff --git a/extensions/sql-bindings/package.nls.json b/extensions/sql-bindings/package.nls.json index 837454cb8c..b946b1cf09 100644 --- a/extensions/sql-bindings/package.nls.json +++ b/extensions/sql-bindings/package.nls.json @@ -1,5 +1,6 @@ { "displayName": "SQL Bindings", "description": "Enables users to develop and publish Azure Functions with Azure SQL bindings", - "sqlBindings.addSqlBinding": "Add SQL Binding (preview)" + "sqlBindings.addSqlBinding": "Add SQL Binding (preview)", + "sqlBindings.createAzureFunction": "Create Azure Function with SQL binding" } diff --git a/extensions/sql-bindings/src/common/azureFunctionsUtils.ts b/extensions/sql-bindings/src/common/azureFunctionsUtils.ts index d75df33fa6..526f6264d8 100644 --- a/extensions/sql-bindings/src/common/azureFunctionsUtils.ts +++ b/extensions/sql-bindings/src/common/azureFunctionsUtils.ts @@ -2,13 +2,16 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as fse from 'fs-extra'; +import * as os from 'os'; +import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import * as utils from './utils'; import * as constants from './constants'; -import { parseJson } from './parseJson'; - +// https://github.com/microsoft/vscode-azurefunctions/blob/main/src/vscode-azurefunctions.api.d.ts +import { AzureFunctionsExtensionApi } from '../typings/vscode-azurefunctions.api'; +// https://github.com/microsoft/vscode-azuretools/blob/main/ui/api.d.ts +import { AzureExtensionApiProvider } from '../typings/vscode-azuretools.api'; /** * Represents the settings in an Azure function project's locawl.settings.json file */ @@ -19,20 +22,25 @@ export interface ILocalSettingsJson { ConnectionStrings?: { [key: string]: string }; } +export interface IFileFunctionObject { + filePromise: Promise; + watcherDisposable: vscode.Disposable; +} + /** * copied and modified from vscode-azurefunctions extension + * https://github.com/microsoft/vscode-azurefunctions/blob/main/src/funcConfig/local.settings.ts * @param localSettingsPath full path to local.settings.json * @returns settings in local.settings.json. If no settings are found, returns default "empty" settings */ export async function getLocalSettingsJson(localSettingsPath: string): Promise { - if (await fse.pathExists(localSettingsPath)) { - const data: string = (await fse.readFile(localSettingsPath)).toString(); - if (/[^\s]/.test(data)) { - try { - return parseJson(data); - } catch (error) { - throw new Error(constants.failedToParse(error.message)); - } + if (fs.existsSync(localSettingsPath)) { + const data: string = (fs.readFileSync(localSettingsPath)).toString(); + try { + return JSON.parse(data); + } catch (error) { + console.log(error); + throw new Error(utils.formatString(constants.failedToParse(error.message), constants.azureFunctionLocalSettingsFileName, error.message)); } } @@ -66,11 +74,210 @@ export async function setLocalAppSetting(projectFolder: string, key: string, val } settings.Values[key] = value; - await fse.writeJson(localSettingsPath, settings, { spaces: 2 }); + void fs.promises.writeFile(localSettingsPath, JSON.stringify(settings, undefined, 2)); return true; } +/** + * Gets the Azure Functions extension API if it is installed + * if it is not installed, prompt the user to install directly, learn more, or do not install + * @returns the Azure Functions extension API if it is installed, prompt if it is not installed + */ +export async function getAzureFunctionsExtensionApi(): Promise { + let apiProvider = await vscode.extensions.getExtension(constants.azureFunctionsExtensionName)?.activate() as AzureExtensionApiProvider; + if (!apiProvider) { + const response = await vscode.window.showInformationMessage(constants.azureFunctionsExtensionNotFound, + constants.install, constants.learnMore, constants.doNotInstall); + if (response === constants.install) { + const extensionInstalled = new Promise((resolve, reject) => { + const timeout = setTimeout(async () => { + reject(new Error(constants.timeoutExtensionError)); + extensionChange.dispose(); + }, 10000); + let extensionChange = vscode.extensions.onDidChange(async () => { + if (vscode.extensions.getExtension(constants.azureFunctionsExtensionName)) { + resolve(); + extensionChange.dispose(); + clearTimeout(timeout); + } + }); + }); + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: constants.azureFunctionsExtensionName, + cancellable: false + }, async (_progress, _token) => { + await vscode.commands.executeCommand('workbench.extensions.installExtension', constants.azureFunctionsExtensionName); + } + ); + // the extension has not been notified that the azure function extension is installed so wait till it is to then activate it + await extensionInstalled; + apiProvider = await vscode.extensions.getExtension(constants.azureFunctionsExtensionName)?.activate() as AzureExtensionApiProvider; + } else if (response === constants.learnMore) { + await vscode.env.openExternal(vscode.Uri.parse(constants.linkToAzureFunctionExtension)); + return undefined; + } else { + return undefined; + } + } + const azureFunctionApi = apiProvider.getApi('*'); + if (azureFunctionApi) { + return azureFunctionApi; + } else { + void vscode.window.showErrorMessage(constants.azureFunctionsExtensionNotInstalled); + return undefined; + } +} + +/** + * TODO REMOVE defaultSqlBindingTextLines + * Overwrites the Azure function methods body to work with the binding + * @param filePath is the path for the function file (.cs for C# functions) + */ +export function overwriteAzureFunctionMethodBody(filePath: string): void { + let defaultBindedFunctionText = fs.readFileSync(filePath, 'utf-8'); + // Replace default binding text + let newValueLines = defaultBindedFunctionText.split(os.EOL); + const defaultFunctionTextToSkip = new Set(constants.defaultSqlBindingTextLines); + let replacedValueLines = []; + for (let defaultLine of newValueLines) { + // Skipped lines + if (defaultFunctionTextToSkip.has(defaultLine.trimStart())) { + continue; + } else if (defaultLine.trimStart() === constants.defaultBindingResult) { // Result change + replacedValueLines.push(defaultLine.replace(constants.defaultBindingResult, constants.sqlBindingResult)); + } else { + // Normal lines to be included + replacedValueLines.push(defaultLine); + } + } + defaultBindedFunctionText = replacedValueLines.join(os.EOL); + fs.writeFileSync(filePath, defaultBindedFunctionText, 'utf-8'); +} + +/** + * Gets the azure function project for the user to choose from a list of projects files + * If only one project is found that project is used to add the binding to + * if no project is found, user is informed there needs to be a C# Azure Functions project + * @returns the selected project file path + */ +export async function getAzureFunctionProject(): Promise { + let selectedProjectFile: string | undefined = ''; + if (vscode.workspace.workspaceFolders === undefined || vscode.workspace.workspaceFolders.length === 0) { + return selectedProjectFile; + } else { + const projectFiles = await getAzureFunctionProjectFiles(); + if (projectFiles !== undefined) { + if (projectFiles.length > 1) { + // select project to add azure function to + selectedProjectFile = (await vscode.window.showQuickPick(projectFiles, { + canPickMany: false, + title: constants.selectProject, + ignoreFocusOut: true + })); + return selectedProjectFile; + } else if (projectFiles.length === 1) { + // only one azure function project found + return projectFiles[0]; + } + } + return undefined; + } +} + +/** + * Gets the azure function project files based on the host file found in the same folder + * @returns the azure function project files paths + */ +export async function getAzureFunctionProjectFiles(): Promise { + let projFiles: string[] = []; + const hostFiles = await getHostFiles(); + if (!hostFiles) { + return undefined; + } + for (let host of hostFiles) { + let projectFile = await vscode.workspace.findFiles(new vscode.RelativePattern(path.dirname(host), '*.csproj')); + projectFile.filter(file => path.dirname(file.fsPath) === path.dirname(host) ? projFiles.push(file?.fsPath) : projFiles); + } + return projFiles.length > 0 ? projFiles : undefined; +} + +/** + * Gets the host files from the workspace + * @returns the host file paths + */ +export async function getHostFiles(): Promise { + const hostUris = await vscode.workspace.findFiles('**/host.json'); + const hostFiles = hostUris.map(uri => uri.fsPath); + return hostFiles.length > 0 ? hostFiles : undefined; +} + +/** + * Gets the local.settings.json file path + * @param projectFile path of the azure function project + * @returns the local.settings.json file path + */ +export async function getSettingsFile(projectFile: string): Promise { + return path.join(path.dirname(projectFile), 'local.settings.json'); +} + +/** + * Retrieves the new function file once the file is created and the watcher disposable + * @param projectFile is the path to the project file + * @returns the function file path once created and the watcher disposable + */ +export function waitForNewFunctionFile(projectFile: string): IFileFunctionObject { + const watcher = vscode.workspace.createFileSystemWatcher(( + path.dirname(projectFile), '**/*.cs'), false, true, true); + const filePromise = new Promise((resolve, _) => { + watcher.onDidCreate((e) => { + resolve(e.fsPath); + }); + }); + return { + filePromise, + watcherDisposable: watcher + }; +} + +/** + * Retrieves the new host project file once it has created and the watcher disposable + * @returns the host file path once created and the watcher disposable + */ +export function waitForNewHostFile(): IFileFunctionObject { + const watcher = vscode.workspace.createFileSystemWatcher('**/host.json', false, true, true); + const filePromise = new Promise((resolve, _) => { + watcher.onDidCreate((e) => { + resolve(e.fsPath); + }); + }); + return { + filePromise, + watcherDisposable: watcher + }; +} + +/** + * Adds the required nuget package to the project + * @param selectedProjectFile is the users selected project file path + */ +export async function addNugetReferenceToProjectFile(selectedProjectFile: string): Promise { + await utils.executeCommand(`dotnet add ${selectedProjectFile} package ${constants.sqlExtensionPackageName} --prerelease`); +} + +/** + * Adds the Sql Connection String to the local.settings.json + * @param connectionString of the SQL Server connection that was chosen by the user + */ +export async function addConnectionStringToConfig(connectionString: string, projectFile: string): Promise { + const settingsFile = await getSettingsFile(projectFile); + if (settingsFile) { + await setLocalAppSetting(path.dirname(settingsFile), constants.sqlConnectionString, connectionString); + } +} + /** * Gets the Azure Functions project that contains the given file if the project is open in one of the workspace folders * @param fileUri file that the containing project needs to be found for @@ -98,13 +305,5 @@ export async function getAFProjectContainingFile(fileUri: vscode.Uri): Promise { - return fse.pathExists(path.join(folderPath, constants.hostFileName)); + return fs.existsSync(path.join(folderPath, constants.hostFileName)); } -/** - * Adds the required nuget package to the project - * @param selectedProjectFile is the users selected project file path - */ -export async function addNugetReferenceToProjectFile(selectedProjectFile: string): Promise { - await utils.executeCommand(`dotnet add ${selectedProjectFile} package ${constants.sqlExtensionPackageName} --prerelease`); -} - diff --git a/extensions/sql-bindings/src/common/constants.ts b/extensions/sql-bindings/src/common/constants.ts index 1096fe182e..f7fda5da4d 100644 --- a/extensions/sql-bindings/src/common/constants.ts +++ b/extensions/sql-bindings/src/common/constants.ts @@ -8,9 +8,40 @@ import * as utils from '../common/utils'; const localize = nls.loadMessageBundle(); +// Azure Functions +export const azureFunctionsExtensionName = 'ms-azuretools.vscode-azurefunctions'; +export const sqlConnectionString = 'SqlConnectionString'; +export const linkToAzureFunctionExtension = 'https://docs.microsoft.com/azure/azure-functions/functions-develop-vs-code'; +export const sqlBindingsDoc = 'https://aka.ms/sqlbindings'; +export const defaultSqlBindingTextLines = + [ + 'log.LogInformation(\"C# HTTP trigger function processed a request.\");', + 'string name = req.Query[\"name\"];', + 'string requestBody = await new StreamReader(req.Body).ReadToEndAsync();', + 'dynamic data = JsonConvert.DeserializeObject(requestBody);', + 'name = name ?? data?.name;', + 'string responseMessage = string.IsNullOrEmpty(name) ? \"This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.\" : $\"Hello, {name}. This HTTP triggered function executed successfully.\";' + ]; +export const defaultBindingResult = 'return new OkObjectResult(responseMessage);'; +export const sqlBindingResult = `return new OkObjectResult(result);`; +export const sqlExtensionPackageName = 'Microsoft.Azure.WebJobs.Extensions.Sql'; +export const functionNameTitle = localize('functionNameTitle', 'Function Name'); +export const selectProject = localize('selectProject', 'Select the Azure Function project for the SQL Binding'); +export const azureFunctionsExtensionNotFound = localize('azureFunctionsExtensionNotFound', 'The Azure Functions extension is required to create a new Azure Function with SQL binding but is not installed, install it now?'); +export const install = localize('install', 'Install'); +export const learnMore = localize('learnMore', 'Learn more'); +export const doNotInstall = localize('doNotInstall', 'Do not install'); +export const createProject = localize('createProject', 'Create Azure Function Project'); +export const selectAzureFunctionProjFolder = localize('selectAzureFunctionProjFolder', 'Select folder for the Azure Function project'); +export const timeoutExtensionError = localize('timeoutExtensionError', 'Timed out waiting for extension to install'); +export const timeoutAzureFunctionFileError = localize('timeoutAzureFunctionFileError', 'Timed out waiting for Azure Function file to be created'); +export const timeoutProjectError = localize('timeoutProjectError', 'Timed out waiting for project to be created'); +export const errorNewAzureFunction = localize('errorNewAzureFunction', 'Error creating new Azure Function: {0}'); +export const azureFunctionsExtensionNotInstalled = localize('azureFunctionsExtensionNotInstalled', 'Azure Functions extension must be installed in order to use this feature.'); +export const azureFunctionsProjectMustBeOpened = localize('azureFunctionsProjectMustBeOpened', 'A C# Azure Functions project must be present in order to create a new Azure Function for this table.'); + // Insert SQL binding export const hostFileName = 'host.json'; -export const sqlExtensionPackageName = 'Microsoft.Azure.WebJobs.Extensions.Sql'; export const placeHolderObject = '[dbo].[table1]'; export const sqlBindingsHelpLink = 'https://github.com/Azure/azure-functions-sql-extension/blob/main/README.md'; export const passwordPlaceholder = '******'; diff --git a/extensions/sql-bindings/src/common/utils.ts b/extensions/sql-bindings/src/common/utils.ts index 3b0f908ec3..78cc9b7034 100644 --- a/extensions/sql-bindings/src/common/utils.ts +++ b/extensions/sql-bindings/src/common/utils.ts @@ -6,7 +6,7 @@ import type * as azdataType from 'azdata'; import * as vscode from 'vscode'; import * as vscodeMssql from 'vscode-mssql'; -import * as fse from 'fs-extra'; +import * as fs from 'fs'; import * as path from 'path'; import * as glob from 'fast-glob'; import * as cp from 'child_process'; @@ -16,6 +16,13 @@ export interface ValidationResult { validated: boolean } +export interface IPackageInfo { + name: string; + fullName: string; + version: string; + aiKey: string; +} + /** * Consolidates on the error message string */ @@ -40,13 +47,6 @@ export async function getVscodeMssqlApi(): Promise { return ext.activate(); } -export interface IPackageInfo { - name: string; - fullName: string; - version: string; - aiKey: string; -} - // Try to load the azdata API - but gracefully handle the failure in case we're running // in a context where the API doesn't exist (such as VS Code) let azdataApi: typeof azdataType | undefined = undefined; @@ -68,14 +68,6 @@ export function getAzdataApi(): typeof azdataType | undefined { return azdataApi; } -export async function createFolderIfNotExist(folderPath: string): Promise { - try { - await fse.mkdir(folderPath); - } catch { - // Ignore if failed - } -} - export async function executeCommand(command: string, cwd?: string): Promise { return new Promise((resolve, reject) => { cp.exec(command, { maxBuffer: 500 * 1024, cwd: cwd }, (error: Error | null, stdout: string, stderr: string) => { @@ -109,6 +101,75 @@ export async function getAllProjectsInFolder(folder: vscode.Uri, projectExtensio return (await glob(projFilter)).map(p => vscode.Uri.file(path.resolve(p))); } +/** + * Format a string. Behaves like C#'s string.Format() function. + */ +export function formatString(str: string, ...args: any[]): string { + // This is based on code originally from https://github.com/Microsoft/vscode/blob/master/src/vs/nls.js + // License: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt + let result: string; + if (args.length === 0) { + result = str; + } else { + result = str.replace(/\{(\d+)\}/g, (match, rest) => { + let index = rest[0]; + return typeof args[index] !== 'undefined' ? args[index] : match; + }); + } + return result; +} + +/** + * Generates a quoted full name for the object + * @param schema of the object + * @param objectName object chosen by the user + * @returns the quoted and escaped full name of the specified schema and object + */ +export function generateQuotedFullName(schema: string, objectName: string): string { + return `[${escapeClosingBrackets(schema)}].[${escapeClosingBrackets(objectName)}]`; +} + +/** + * Returns a promise that will reject after the specified timeout + * @param errorMessage error message to be returned in the rejection + * @param ms timeout in milliseconds. Default is 10 seconds + * @returns a promise that rejects after the specified timeout + */ +export function timeoutPromise(errorMessage: string, ms: number = 10000): Promise { + return new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(errorMessage)); + }, ms); + }); +} + +/** + * Gets a unique file name + * Increment the file name by adding 1 to function name if the file already exists + * Undefined if the filename suffix count becomes greater than 1024 + * @param folderPath selected project folder path + * @param fileName base filename to use + * @returns a promise with the unique file name, or undefined + */ +export async function getUniqueFileName(folderPath: string, fileName: string): Promise { + let count: number = 0; + const maxCount: number = 1024; + let uniqueFileName = fileName; + + while (count < maxCount) { + if (!fs.existsSync(path.join(folderPath, uniqueFileName + '.cs'))) { + return uniqueFileName; + } + count += 1; + uniqueFileName = fileName + count.toString(); + } + return undefined; +} + +export function escapeClosingBrackets(str: string): string { + return str.replace(']', ']]'); +} + /** * Gets the package info for the extension based on where the extension is installed * @returns the package info object diff --git a/extensions/sql-bindings/src/extension.ts b/extensions/sql-bindings/src/extension.ts index 475924282e..49d6e7d1fa 100644 --- a/extensions/sql-bindings/src/extension.ts +++ b/extensions/sql-bindings/src/extension.ts @@ -3,12 +3,35 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { getAzdataApi } from './common/utils'; +import { ITreeNodeInfo } from 'vscode-mssql'; +import { getAzdataApi, getVscodeMssqlApi } from './common/utils'; import { launchAddSqlBindingQuickpick } from './dialogs/addSqlBindingQuickpick'; +import { createAzureFunction } from './services/azureFunctionsService'; + +export async function activate(context: vscode.ExtensionContext): Promise { + const vscodeMssqlApi = await getVscodeMssqlApi(); -export function activate(context: vscode.ExtensionContext): void { void vscode.commands.executeCommand('setContext', 'azdataAvailable', !!getAzdataApi()); + // register the add sql binding command context.subscriptions.push(vscode.commands.registerCommand('sqlBindings.addSqlBinding', async (uri: vscode.Uri | undefined) => { return launchAddSqlBindingQuickpick(uri); })); + // Generate Azure Function command + context.subscriptions.push(vscode.commands.registerCommand('sqlBindings.createAzureFunction', async (node: ITreeNodeInfo) => { + let connectionInfo = node.connectionInfo; + // set the database containing the selected table so it can be used + // for the initial catalog property of the connection string + let newNode: ITreeNodeInfo = node; + while (newNode) { + if (newNode.nodeType === 'Database') { + connectionInfo.database = newNode.metadata.name; + break; + } else { + newNode = newNode.parentNode; + } + } + const connectionDetails = vscodeMssqlApi.createConnectionDetails(connectionInfo); + const connectionString = await vscodeMssqlApi.getConnectionString(connectionDetails, false, false); + await createAzureFunction(connectionString, node.metadata.schema, node.metadata.name); + })); } export function deactivate(): void { diff --git a/extensions/sql-bindings/src/services/azureFunctionsService.ts b/extensions/sql-bindings/src/services/azureFunctionsService.ts new file mode 100644 index 0000000000..934fca6f58 --- /dev/null +++ b/extensions/sql-bindings/src/services/azureFunctionsService.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as mssql from 'vscode-mssql'; +import * as path from 'path'; +import * as utils from '../common/utils'; +import * as azureFunctionUtils from '../common/azureFunctionsUtils'; +import * as constants from '../common/constants'; + +export const hostFileName: string = 'host.json'; + + +export async function createAzureFunction(connectionString: string, schema: string, table: string): Promise { + const azureFunctionApi = await azureFunctionUtils.getAzureFunctionsExtensionApi(); + if (!azureFunctionApi) { + return; + } + let projectFile = await azureFunctionUtils.getAzureFunctionProject(); + let newHostProjectFile!: azureFunctionUtils.IFileFunctionObject; + let hostFile: string; + + if (!projectFile) { + let projectCreate = await vscode.window.showErrorMessage(constants.azureFunctionsProjectMustBeOpened, + constants.createProject, constants.learnMore); + if (projectCreate === constants.learnMore) { + void vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(constants.sqlBindingsDoc)); + return; + } else if (projectCreate === constants.createProject) { + // start the create azure function project flow + try { + // because of an AF extension API issue, we have to get the newly created file by adding a watcher + // issue: https://github.com/microsoft/vscode-azurefunctions/issues/3052 + newHostProjectFile = await azureFunctionUtils.waitForNewHostFile(); + await azureFunctionApi.createFunction({}); + const timeoutForHostFile = utils.timeoutPromise(constants.timeoutProjectError); + hostFile = await Promise.race([newHostProjectFile.filePromise, timeoutForHostFile]); + if (hostFile) { + // start the add sql binding flow + projectFile = await azureFunctionUtils.getAzureFunctionProject(); + } + } catch (error) { + void vscode.window.showErrorMessage(utils.formatString(constants.errorNewAzureFunction, error.message ?? error)); + return; + } finally { + newHostProjectFile.watcherDisposable.dispose(); + } + } + } + + if (projectFile) { + // because of an AF extension API issue, we have to get the newly created file by adding a watcher + // issue: https://github.com/microsoft/vscode-azurefunctions/issues/2908 + const newFunctionFileObject = azureFunctionUtils.waitForNewFunctionFile(projectFile); + let functionFile: string; + let functionName: string; + + try { + // get function name from user + let uniqueFunctionName = await utils.getUniqueFileName(path.dirname(projectFile), table); + functionName = await vscode.window.showInputBox({ + title: constants.functionNameTitle, + value: uniqueFunctionName, + ignoreFocusOut: true, + validateInput: input => input ? undefined : constants.nameMustNotBeEmpty + }) as string; + if (!functionName) { + return; + } + + // create C# HttpTrigger + await azureFunctionApi.createFunction({ + language: 'C#', + templateId: 'HttpTrigger', + functionName: functionName, + folderPath: projectFile + }); + + // check for the new function file to be created and dispose of the file system watcher + const timeoutForFunctionFile = utils.timeoutPromise(constants.timeoutAzureFunctionFileError); + functionFile = await Promise.race([newFunctionFileObject.filePromise, timeoutForFunctionFile]); + } finally { + newFunctionFileObject.watcherDisposable.dispose(); + } + + // select input or output binding + const inputOutputItems: (vscode.QuickPickItem & { type: mssql.BindingType })[] = [ + { + label: constants.input, + type: mssql.BindingType.input + }, + { + label: constants.output, + type: mssql.BindingType.output + } + ]; + + const selectedBinding = await vscode.window.showQuickPick(inputOutputItems, { + canPickMany: false, + title: constants.selectBindingType, + ignoreFocusOut: true + }); + + if (!selectedBinding) { + return; + } + + await azureFunctionUtils.addNugetReferenceToProjectFile(projectFile); + await azureFunctionUtils.addConnectionStringToConfig(connectionString, projectFile); + + let objectName = utils.generateQuotedFullName(schema, table); + const azureFunctionsService = await utils.getAzureFunctionService(); + await azureFunctionsService.addSqlBinding( + selectedBinding.type, + functionFile, + functionName, + objectName, + constants.sqlConnectionString + ); + + azureFunctionUtils.overwriteAzureFunctionMethodBody(functionFile); + } +} diff --git a/extensions/sql-bindings/src/test/testUtils.ts b/extensions/sql-bindings/src/test/testUtils.ts index b6ad118841..c045d4d66f 100644 --- a/extensions/sql-bindings/src/test/testUtils.ts +++ b/extensions/sql-bindings/src/test/testUtils.ts @@ -269,6 +269,9 @@ export class MockVscodeMssqlIExtension implements vscodeMssql.IExtension { getConnectionString(_: string | vscodeMssql.ConnectionDetails, ___?: boolean, _____?: boolean): Promise { throw new Error('Method not implemented.'); } + createConnectionDetails(_: vscodeMssql.IConnectionInfo): vscodeMssql.ConnectionDetails { + throw new Error('Method not implemented.'); + } } export function createTestUtils(): TestUtils { @@ -351,6 +354,7 @@ export function createTestCredentials(): vscodeMssql.IConnectionInfo { password: '12345678', email: 'test-email', accountId: 'test-account-id', + tenantId: 'test-tenant-id', port: 1234, authenticationType: 'test', azureAccountToken: '', diff --git a/extensions/sql-bindings/src/typings/vscode-azurefunctions.api.d.ts b/extensions/sql-bindings/src/typings/vscode-azurefunctions.api.d.ts new file mode 100644 index 0000000000..c07ab53718 --- /dev/null +++ b/extensions/sql-bindings/src/typings/vscode-azurefunctions.api.d.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface AzureFunctionsExtensionApi { + apiVersion: string; + + revealTreeItem(resourceId: string): Promise; + + createFunction(options: ICreateFunctionOptions): Promise; + downloadAppSettings(client: IAppSettingsClient): Promise; + uploadAppSettings(client: IAppSettingsClient, exclude?: (RegExp | string)[]): Promise; +} + +export type ProjectLanguage = 'JavaScript' | 'TypeScript' | 'C#' | 'Python' | 'PowerShell' | 'Java'; +export type ProjectVersion = '~1' | '~2' | '~3' | '~4'; + +export interface IAppSettingsClient { + fullName: string; + listApplicationSettings(): Promise; + updateApplicationSettings(appSettings: IStringDictionary): Promise; +} + +interface IStringDictionary { + properties?: { [propertyName: string]: string }; +} + + +/** + * The options to use when creating a function. If an option is not specified, the default will be used or the user will be prompted + */ +export interface ICreateFunctionOptions { + /** + * The folder containing the Azure Functions project + */ + folderPath?: string; + + /** + * The name of the function + */ + functionName?: string; + + /** + * The language of the project + */ + language?: ProjectLanguage; + + /** + * A filter specifying the langauges to display when creating a project (if there's not already a project) + */ + languageFilter?: RegExp; + + /** + * The version of the project. Defaults to the latest GA version + */ + version?: ProjectVersion; + + /** + * The id of the template to use. + * NOTE: The language part of the id is optional. Aka "HttpTrigger" will work just as well as "HttpTrigger-JavaScript" + */ + templateId?: string; + + /** + * A case-insensitive object of settings to use for the function + */ + functionSettings?: { + [key: string]: string | undefined + } + + /** + * If set to true, it will automatically create a new project without prompting (if there's not already a project). Defaults to false + */ + suppressCreateProjectPrompt?: boolean; + + /** + * If set to true, it will not try to open the folder after create finishes. Defaults to false + */ + suppressOpenFolder?: boolean; + + /** + * If set, it will automatically select the worker runtime for .NET with the matching targetFramework + */ + targetFramework?: string | string[]; +} diff --git a/extensions/sql-bindings/src/typings/vscode-azuretools.api.d.ts b/extensions/sql-bindings/src/typings/vscode-azuretools.api.d.ts new file mode 100644 index 0000000000..62a33e2f5e --- /dev/null +++ b/extensions/sql-bindings/src/typings/vscode-azuretools.api.d.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// For now this file needs to be copied/pasted into your repo if you want the types. Eventually we may put it somewhere more distributable. + +export interface AzureExtensionApi { + /** + * The API version for this extension. It should be versioned separately from the extension and ideally remains backwards compatible. + */ + apiVersion: string; +} + +export interface AzureExtensionApiProvider { + /** + * Provides the API for an Azure Extension. + * + * @param apiVersionRange The version range of the API you need. Any semver syntax is allowed. For example "1" will return any "1.x.x" version or "1.2" will return any "1.2.x" version + * @throws Error if a matching version is not found. + */ + getApi(apiVersionRange: string): T; +} diff --git a/extensions/sql-bindings/src/typings/vscode-mssql.d.ts b/extensions/sql-bindings/src/typings/vscode-mssql.d.ts index 7a5f03c460..ad06f97c78 100644 --- a/extensions/sql-bindings/src/typings/vscode-mssql.d.ts +++ b/extensions/sql-bindings/src/typings/vscode-mssql.d.ts @@ -84,6 +84,14 @@ declare module 'vscode-mssql' { * @returns connection string for the connection */ getConnectionString(connectionUriOrDetails: string | ConnectionDetails, includePassword?: boolean, includeApplicationName?: boolean): Promise; + + /** + * Set connection details for the provided connection info + * Able to use this for getConnectionString requests to STS that require ConnectionDetails type + * @param connectionInfo connection info of the connection + * @returns connection details credentials for the connection + */ + createConnectionDetails(connectionInfo: IConnectionInfo): ConnectionDetails; } /** @@ -120,6 +128,11 @@ declare module 'vscode-mssql' { */ accountId: string | undefined; + /** + * tenantId + */ + tenantId: string | undefined; + /** * The port number to connect to. */ diff --git a/extensions/sql-bindings/yarn.lock b/extensions/sql-bindings/yarn.lock index 72dc5fe8f3..af281673dc 100644 --- a/extensions/sql-bindings/yarn.lock +++ b/extensions/sql-bindings/yarn.lock @@ -293,17 +293,10 @@ resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5" integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ== -"@types/fs-extra@^5.0.0": - version "5.1.0" - resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-5.1.0.tgz#2a325ef97901504a3828718c390d34b8426a10a1" - integrity sha512-AInn5+UBFIK9FK5xc9yP5e3TQSPNNgjHByqYcj9g5elVBnDQcQL7PlO1CIRy2gWlbwK7UPYqi7vRvFA44dCmYQ== - dependencies: - "@types/node" "*" - -"@types/node@*": - version "17.0.21" - resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.21.tgz#864b987c0c68d07b4345845c3e63b75edd143644" - integrity sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ== +"@types/node@^14.14.16": + version "14.18.12" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.12.tgz#0d4557fd3b94497d793efd4e7d92df2f83b4ef24" + integrity sha512-q4jlIR71hUpWTnGhXWcakgkZeHa3CCjcQcnuzU8M891BAWA2jHiziiWEPEkdS5pFsz7H9HJiy8BrK7tBRNrY7A== ansi-regex@^3.0.0: version "3.0.0" @@ -591,15 +584,6 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" -fs-extra@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-5.0.0.tgz#414d0110cdd06705734d055652c5411260c31abd" - integrity sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ== - dependencies: - graceful-fs "^4.1.2" - jsonfile "^4.0.0" - universalify "^0.1.0" - fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -651,11 +635,6 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -graceful-fs@^4.1.2, graceful-fs@^4.1.6: - version "4.2.9" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96" - integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ== - growl@1.10.5: version "1.10.5" resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" @@ -820,13 +799,6 @@ jsonc-parser@^2.3.1: resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-2.3.1.tgz#59549150b133f2efacca48fe9ce1ec0659af2342" integrity sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg== -jsonfile@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" - integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= - optionalDependencies: - graceful-fs "^4.1.6" - just-extend@^4.0.2: version "4.2.1" resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744" @@ -1259,11 +1231,6 @@ typemoq@^2.1.0: lodash "^4.17.4" postinstall-build "^5.0.1" -universalify@^0.1.0: - version "0.1.2" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" - integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== - vscode-extension-telemetry@^0.1.6: version "0.1.7" resolved "https://registry.yarnpkg.com/vscode-extension-telemetry/-/vscode-extension-telemetry-0.1.7.tgz#18389bc24127c89dade29cd2b71ba69a6ee6ad26"