From d219fc9407e3be220a655c730f51c055713e711b Mon Sep 17 00:00:00 2001 From: Tobias Fenster Date: Sun, 7 Jan 2018 03:02:31 +0100 Subject: [PATCH] release 3.0: Connect API support --- README.md | 15 ++- package.json | 22 ++++- src/extension.ts | 243 ++++++++++++++++++++++++++++++++++++++++++----- src/templates.ts | 36 +++++++ 4 files changed, 289 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index e7230cc..e7c51ae 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,32 @@ AL Runner allows you to compile and publish your application and run a NAV AL page object using Alt+P for the object in the current selection or Shift+Alt+P for the first object in the file. It also allows you to just run (no compile and publish) a NAV AL page or report object using Alt+R like in good old (actually bad old) C/SIDE for the object in the current selection or Shift+Alt+R for the first object in the file. The second part of AL Runner allows you to parse a JSON object and generate AL objects from it (leaning heavily on [AJ Kauffmann's Github repo](https://github.com/ajkauffmann/ALCodeSamples), thanks for that!) +The third part of AL Runner gives you a quick and easy way to get started with the NAV Connect API (see command "ALRunner: Go API on Azure!") ## Features -Provides five commands: +Provides six commands: - "ALRunner: Run selection" or Alt+R which runs the object in the currently selected line, if that is a page or report object - "ALRunner: Run object on first line" or Shift+Alt+R which runs the object on the first line of the current file, if that is a page or report object - "ALRunner: Publish and run selection" or Alt+P which publishes your extension and runs the object in the currently selected line, if that is a page object. Note: This changes your launch config - "ALRunner: Publish and run object on first line" or Shift+Alt+P which publishes your extension and runs the object on the first line of the current file, if that is a page object. Note: This changes your launch config - "ALRunner: Generate objects by parsing a JSON object from a URL" asks you for a URL, which should return a JSON file (authentication currently not supported) and then for the name of the entity represented by that JSON file. It then generates a table structured like the JSON file, a page showing that table and a codeunit with a function to refresh that data - "ALRunner: Generate objects by parsing a JSON object in the current selection" does the same as the previous command but uses the currently selected text as data for parsing. This als asks you for a URL, which is only used for generating the AL code do download data and then for the name of the entity represented by that JSON file. With this command you code download a JSON object using authentication and then put the result into VS Code, select the relevant part and let generation run. For nested objects you could do the same but would need to handle linking parent and child objects +- "ALRunner: Go API on Azure!" asks you to log in to your Azure account and select the subscription and resource group you want to use. It then uses an ARM template to create a Azure Container Instance for NAV 2018 with enabld Connect API and generates a sample client for it ## Requirements - You need to have the Microsoft AL Extension up and running (easiest way to get to that point is [here](https://msdn.microsoft.com/en-us/dynamics-nav/newdev-get-started)) +- It depends on the REST Client Extension by Huachao Mao - opn ^4.0.2 - request ^2.83.0 +- download-file ^0.1.5 +- azure-arm-resource ^3.1.0-preview +- ms-rest ^2.3.0 +- ms-rest-azure ^2.5.0 +- copy-paste ^1.3.0 ## Known Issues @@ -37,12 +45,17 @@ Provides five commands: - Find out which base page object a pageextension changes and run that - Allow to configure if full, tablet or phone Web Client is run - Support authorization and nested structures in JSON +- Extend the Connect API samples, e.g. creating a customer or working with other entities ## Release Notes Notes for the released versions +### 3.0.0 + +Add the ability to create a running Azure Container Instace for NAV 2018 with enabled Connect API and generate a sample client + ### 2.4.1 Add double quotes around field names diff --git a/package.json b/package.json index 01cc190..3ddb220 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "alrunner", "displayName": "ALRunner", "description": "Can run AL objects", - "version": "2.4.1", + "version": "3.0.0", "publisher": "tfenster", "repository": "https://github.com/tfenster/ALRunner", "engines": { @@ -17,7 +17,9 @@ "onCommand:extension.publishAndRunSelection", "onCommand:extension.publishAndRunFirstObject", "onCommand:extension.generateObjectsFromURL", - "onCommand:extension.generateObjectsFromEditor" + "onCommand:extension.generateObjectsFromEditor", + "onCommand:extension.goAzure", + "workspaceContains:initializeme.alrunner" ], "main": "./out/src/extension", "contributes": { @@ -45,6 +47,10 @@ { "command": "extension.generateObjectsFromEditor", "title": "ALRunner: Generate objects by parsing a JSON object in the current selection" + }, + { + "command": "extension.goAzure", + "title": "ALRunner: Go API on Azure!" } ], "keybindings": [ @@ -89,6 +95,14 @@ }, "dependencies": { "opn": "^4.0.2", - "request": "^2.83.0" - } + "request": "^2.83.0", + "download-file": "^0.1.5", + "azure-arm-resource": "^3.1.0-preview", + "ms-rest": "^2.3.0", + "ms-rest-azure": "^2.5.0", + "copy-paste": "^1.3.0" + }, + "extensionDependencies": [ + "humao.rest-client" + ] } \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 1c2a2ed..1c7c6ba 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,12 +1,20 @@ 'use strict'; -import {window, commands, Disposable, ExtensionContext, StatusBarAlignment, StatusBarItem, TextDocument, TextEditor, ViewColumn, workspace, TextLine, TextEdit} from 'vscode'; +import { window, commands, Disposable, ExtensionContext, StatusBarAlignment, StatusBarItem, TextDocument, TextEditor, ViewColumn, workspace, TextLine, TextEdit, Uri } from 'vscode'; import { worker } from 'cluster'; import * as templates from './templates'; import * as http from 'http'; import { tableTemplateAfter, tableKeyTemplate } from './templates'; +import { basename } from 'path'; const open = require('opn'); const fs = require('fs'); +const request = require('request'); +const download = require('download-file'); +const path = require('path'); +const ResourceManagement = require('azure-arm-resource'); +const msRest = require('ms-rest'); +const msRestAzure = require('ms-rest-azure'); +const cp = require('copy-paste'); export function activate(context: ExtensionContext) { let alr = new ALRunner(); @@ -35,12 +43,93 @@ export function activate(context: ExtensionContext) { alr.generateObjectsFromEditor(window.activeTextEditor); }); + let disp7 = commands.registerCommand('extension.goAzure', () => { + alr.goAzure(); + }); + context.subscriptions.push(disp); context.subscriptions.push(disp2); context.subscriptions.push(disp3); context.subscriptions.push(disp4); context.subscriptions.push(disp5); context.subscriptions.push(disp6); + context.subscriptions.push(disp7); + + workspace.findFiles('initializeme.alrunner').then( + r => { + let basePath = workspace.rootPath; + fs.unlinkSync(path.join(basePath, 'initializeme.alrunner')); + + let baseURL = 'https://raw.githubusercontent.com/tfenster/azure-quickstart-templates/api-enabled-nav/aci-dynamicsnav-api-enabled/'; + + download(baseURL + 'azuredeploy.json', { + directory: path.join(basePath, 'arm-templates', 'aci-dynamicsnav-api-enabled'), + filename: 'azuredeploy.json' + }); + + download(baseURL + 'azuredeploy.parameters.json', { + directory: path.join(basePath, 'arm-templates', 'aci-dynamicsnav-api-enabled'), + filename: 'azuredeploy.parameters.json' + }); + + let options = { + userCodeResponseLogger: function (message) { + let startsWithCode = message.substring(message.indexOf('devicelogin and enter the code ') + 31); + let codeOnly = startsWithCode.substring(0, startsWithCode.indexOf(' ')); + cp.copy(codeOnly); + + window.showInformationMessage( + 'You will now need to log in to Azure. Click log in and paste the ID ' + codeOnly + ' that is already copied to the clipboard into the entry field', { + title: 'Log in' + }).then(function (btn) { + if (btn && btn.title == 'Log in') { + open('https://aka.ms/devicelogin'); + } + }); + } + }; + + msRestAzure.interactiveLogin(options, function (err, credentials, subscriptions) { + if (err) { + console.log(err); + return; + } + window.showInformationMessage('Now you will need to select the subscription and resource group you want to use').then(r => { + let subscriptionsForPick = []; + subscriptions.forEach(element => { + subscriptionsForPick.push(element.name); + }); + window.showQuickPick(subscriptionsForPick) + .then(selected => { + let selectedSub = subscriptions.filter(sub => { + return sub.name == selected; + }); + alr.deployTemplate(credentials, function (err, result) { + if (err) + return console.log(err); + console.log('template deployed to azure! Now wait a bit as it takes some time until the container is actually reachable'); + setTimeout(function () { + let ip = result.properties.outputs.containerIPv4Address.value; + window.showInformationMessage( + 'Deployment was successful! You can reach the WebClient at https://' + ip + '/nav/webclient', { + title: 'Get access data' + }).then(function (btn) { + if (btn && btn.title == 'Get access data') { + open('http://' + ip + ':8080/accessdata.html'); + } + alr.generateAPIClient(ip); + }); + }, 2 * 60 * 1000); + }, + path.join(workspace.rootPath, 'arm-templates', 'aci-dynamicsnav-api-enabled', 'azuredeploy.json'), + path.join(workspace.rootPath, 'arm-templates', 'aci-dynamicsnav-api-enabled', 'azuredeploy.parameters.json'), + selectedSub[0].id); + }); + }); + }); + + } + ); } // this method is called when your extension is deactivated @@ -56,7 +145,7 @@ class ALRunner { public runSelection(editor: TextEditor) { // find the currently active line let line = editor.document.lineAt(editor.selection.active.line); - + this.runObjectOnLine(line, false); } @@ -70,7 +159,7 @@ class ALRunner { public publishAndRunSelection(editor: TextEditor) { // find the currently active line let line = editor.document.lineAt(editor.selection.active.line); - + this.runObjectOnLine(line, true); } @@ -83,13 +172,13 @@ class ALRunner { public generateObjectsFromEditor(editor: TextEditor) { let alr = this; - window.showInputBox({prompt: 'From which URL do you want to read JSON data? (only for generating access code in AL)'}) + window.showInputBox({ prompt: 'From which URL do you want to read JSON data? (only for generating access code in AL)' }) .then(val => { if (val === undefined) { return; } - window.showInputBox({prompt: 'What entity are you reading?'}) + window.showInputBox({ prompt: 'What entity are you reading?' }) .then(val2 => { if (val2 === undefined) { return; @@ -103,17 +192,16 @@ class ALRunner { public generateObjectsFromURL() { let alr = this; - window.showInputBox({prompt: 'From which URL do you want to read JSON data?'}) + window.showInputBox({ prompt: 'From which URL do you want to read JSON data?' }) .then(val => { if (val === undefined) { return; } - var request = require('request'); var reqOptions = { url: val, headers: { - 'User-Agent': 'request' + 'User-Agent': 'request' } }; @@ -129,7 +217,7 @@ class ALRunner { window.showErrorMessage('Failed to read the content. Status code ' + response.statusCode + ' and body: ' + body); return; } - window.showInputBox({prompt: 'What entity are you reading?'}) + window.showInputBox({ prompt: 'What entity are you reading?' }) .then(val2 => { if (val2 === undefined) { return; @@ -138,11 +226,122 @@ class ALRunner { }); }); }); - + + } + + public goAzure() { + window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: 'Use this folder' + }).then(r => { + fs.writeFile(path.join(r[0].fsPath, 'initializeme.alrunner'), '', (err) => { + if (err) { + console.log(err); + return; + } + commands.executeCommand("vscode.openFolder", r[0], false); + }); + }); + } + + public deployTemplate(credentials, callback, templateFilePath, templateParametersFilePath, subscriptionId) { + let resourceGroupName = ''; + let deploymentName = 'acinavapi'; + + let template; + let templateParameters; + + try { + template = JSON.parse(fs.readFileSync(templateFilePath)); + templateParameters = JSON.parse(fs.readFileSync(templateParametersFilePath)); + } catch (error) { + callback(error); + } + + if (templateParameters.parameters) + templateParameters = templateParameters.parameters; + + var parameters = { + properties: { + template: template, + parameters: templateParameters, + mode: 'Complete' + } + }; + + var resourceClient = new ResourceManagement.ResourceManagementClient(credentials, subscriptionId); + resourceClient.resourceGroups.list(function (err, result) { + let rgs = []; + result.forEach(element => { + rgs.push(element.name); + }); + window.showQuickPick(rgs) + .then(selected => { + window.showInformationMessage('Deployment was started'); + resourceClient.deployments.createOrUpdate(selected, deploymentName, parameters, callback); + }); + }); + } + + public generateAPIClient(ip: string) { + let reqOptions = { + url: 'http://' + ip + ':8080/accessdata.html', + headers: { + 'User-Agent': 'request' + } + }; + + request(reqOptions, function (error, response, body) { + let username = body.substring(body.indexOf('Username:') + 9); + username = username.substring(0, username.indexOf('
')); + + let password = body.substring(body.indexOf('Password:') + 9); + password = password.substring(0, password.indexOf('

')); + + let reqOptionsAuth = { + url: 'https://' + ip + ':7048/nav/api/beta/customers', + headers: { + 'User-Agent': 'request' + }, + auth: { + user: username, + pass: password, + sendImmediately: false + }, + rejectUnauthorized: false, + strictSSL: false + }; + + request(reqOptionsAuth, function (error, response, body) { + let jsonObject = JSON.parse(body); + let custid = jsonObject.value[0].id; + let custname = jsonObject.value[0].displayName; + let etag = jsonObject.value[0]["@odata.etag"]; + + let httpContent = templates.APIClientTemplate.replace(/##username##/g, username); + httpContent = httpContent.replace(/##password##/g, password); + httpContent = httpContent.replace(/##ip##/g, ip); + httpContent = httpContent.replace(/##custid##/g, custid); + httpContent = httpContent.replace(/##custname##/g, custname); + httpContent = httpContent.replace(/##etag##/g, etag); + + fs.writeFile(path.join(workspace.rootPath, 'sample.http'), httpContent, (err) => { + if (err) { + console.log(err); + return; + } + workspace.openTextDocument(path.join(workspace.rootPath, 'sample.http')); + }); + }); + }); + + } private DoGenerate(jsonText: string, entity: string, url: string) { - + let jsonObject = JSON.parse(jsonText); //console.log('is array: ' + Array.isArray(jsonObject)); @@ -150,7 +349,7 @@ class ALRunner { jsonObject = jsonObject[0]; } - let members : Member[] = [] + let members: Member[] = [] Object.getOwnPropertyNames(jsonObject).forEach( function (val, idx, array) { @@ -181,7 +380,7 @@ class ALRunner { let count = 1; let hasId = false; members.forEach(m => { - let newField = templates.tableFieldTemplate.replace('##id##', ''+count).replace(/##name##/g, m.name); + let newField = templates.tableFieldTemplate.replace('##id##', '' + count).replace(/##name##/g, m.name); if (m.type === 'Text') { newField = newField.replace('##type##', 'Text[250]'); } else { @@ -192,7 +391,7 @@ class ALRunner { } tableContent += newField; count++; - }); + }); if (hasId) { tableContent += templates.tableKeyTemplate; } @@ -202,7 +401,7 @@ class ALRunner { let pageContent = templates.pageTemplateBefore.replace(/##entity##/g, entity); members.forEach(m => { pageContent += templates.pageFieldTemplate.replace(/##name##/g, m.name); - }); + }); pageContent += templates.pageTemplateAfter.replace(/##entity##/g, entity); this.generateAndOpenFile(pageContent); @@ -213,7 +412,7 @@ class ALRunner { } else { codeunitContent += templates.codeunitFieldTemplate.replace(/##name##/g, m.name).replace('##type##', m.type).replace(/##entity##/g, entity); } - }); + }); codeunitContent += templates.codeunitTemplateAfter.replace(/##entity##/g, entity); this.generateAndOpenFile(codeunitContent); } @@ -222,13 +421,13 @@ class ALRunner { let options: Object = { content: content, language: "al" - }; - - workspace.openTextDocument(options).then(doc => { + }; + + workspace.openTextDocument(options).then(doc => { window.showTextDocument(doc, { preview: false }); - }, err => { + }, err => { window.showErrorMessage(err); - }); + }); } private runObjectOnLine(line: TextLine, publish: boolean) { @@ -270,4 +469,4 @@ class ALRunner { } } } -} \ No newline at end of file +} diff --git a/src/templates.ts b/src/templates.ts index 43a9cec..ce60c50 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -166,4 +166,40 @@ export const codeunitTemplateAfter: string = ` end; } +`; + +export const APIClientTemplate: string = `@baseurl = https://##ip##:7048 +@auth = Basic ##username## ##password## +@apibase = /nav/api/beta + +### +# get all services +GET {{baseurl}}{{apibase}}/ +Authorization: {{auth}} + +### +# get all customers +GET {{baseurl}}{{apibase}}/customers +Authorization: {{auth}} + +### +# filter customers +GET {{baseurl}}{{apibase}}/customers?$filter=displayName eq '##custname##' +Authorization: {{auth}} + +### +# get a specific customer +GET {{baseurl}}{{apibase}}/customers(##custid##) +Authorization: {{auth}} + +### +# change a customer +PATCH {{baseurl}}{{apibase}}/customers(##custid##) +Authorization: {{auth}} +Content-type: application/json +If-Match: ##etag## + +{ + "displayName": "Axians Infoma" +} `; \ No newline at end of file