Codeunit 2501 Extension Marketplace, source in 24
Source242526272829metadata in 24
src/System Application/App/Extension Management/src/ExtensionMarketplace.Codeunit.al499 lines, Copyright (c) Microsoft Corporation. MIT
// ------------------------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
// ------------------------------------------------------------------------------------------------
namespace System.Apps;
using System;
using System.Utilities;
using System.Azure.KeyVault;
using System.Environment;
using System.Environment.Configuration;
/// <summary>
/// This codeunit is used as a helper for managing interactions between
/// Business Central and the AppSource marketplace. The marketplace provides a gallery
/// that users can use to select and install Extensions published (an thus
/// available) on Business Central.
/// When and item is selected from the gallery, a JSON object is returned
/// and needs to be parsed for the key information we need to perform an
/// install.
/// At current the key pieces of that object look like this:
/// "msgType":"<type name string>",
/// "data":
/// "applicationId":"<string identifier for selected extension>",
/// "telemetryUrl":"<url>",
/// </summary>
codeunit 2501 "Extension Marketplace"
{
Access = Internal;
Permissions = tabledata "Extension Pending Setup" = rimd,
tabledata "Published Application" = r;
var
HttpWebRequest: DotNet HttpWebRequest;
GlobalPropertyValue: Text;
ParseFailureErr: Label 'Failed to extract ''%1'' property from JSON object.', Comment = 'JSON parsing error. %1=target property name';
TelemetryBodyTxt: Label '{"acquisitionResult":"%1", "detail":"%2"}', Comment = '%1=AppSource operation result option, %2=details describing the context or reason for the result', Locked = true;
ParseApplicationIdErr: Label 'Failed to extract ''%1'' token from Application Id.', Comment = '%1=Name of token that we expected ';
Token: Option PUBID,AID,PAPPID;
MarketplaceDisabledSecretTxt: Label 'extmgmt-marketplace-disable', Locked = true;
MarketPlaceSuccInstallTxt: Label 'The extension was successfully installed.';
MarketPlaceUnsuccInstallTxt: Label 'The market place extension installation has failed with the result ''%1''. Error message: ''%2''', Comment = '%1 - OperationResult parameter value, %2 - Error message';
AlreadyInstalledMsg: Label 'The extension %1 is already installed.', Comment = '%1=name of app';
AppsourceTxt: Label 'https://appsource.microsoft.com', Locked = true;
EmbedRelativeTxt: Label '/embed/en-us/marketplace?product=dynamics-365-business-central', Locked = true;
ExtensionNotFoundErr: Label 'Selected extension could not be installed because a valid App Id is not passed.', Comment = 'Error message for trying to install an extension where a valid id is not passed;';
TelemetryExtensionNotFoundErr: Label 'Selected extension could not be installed because a valid App Id is not passed. Application ID : %1.', Comment = 'Telemetry error message for trying to install an extension a valid id is not passed; %1 is the applicaiton id recieved from appsource.';
MissingAppIdErr: Label 'Selected extension could not be installed because the extension is not published and a valid App Id is not passed. Application ID : %1.', Comment = 'Telemetry error message for trying to install an extension a valid id is not passed; %1 is the applicaiton id recieved from appsource.';
TelemetryTok: Label 'ExtensionManagementTelemetryCategoryTok', Locked = true;
OperationResult: Option UserNotAuthorized,DeploymentFailedDueToPackage,DeploymentFailed,Successful,UserCancel,UserTimeOut;
AppDoesntNeedSetupMsg: Label 'Your app is installed and ready to use.';
local procedure GetValue(JObject: DotNet JObject; Property: Text; ThrowError: Boolean): Text
begin
// Helper for extracting a property value out of a JObject
if TryGetValue(JObject, Property) then
exit(GlobalPropertyValue);
if ThrowError then
Error(ParseFailureErr, Property);
exit('');
end;
[TryFunction]
local procedure TryGetValue(JObject: DotNet JObject; Property: Text)
var
StringComparison: DotNet StringComparison;
JToken: DotNet JToken;
begin
// Helper to 'safely' extract the value of a JProperty. Ignores case and 'catches' exceptions
JToken := JObject.GetValue(Property, StringComparison.OrdinalIgnoreCase);
GlobalPropertyValue := JToken.ToString();
end;
procedure GetTelementryUrlFromData(JObject: DotNet JObject): Text
var
TempObject: DotNet JObject;
begin
// Extracts the telemetryUrl property, out of the data object, return by the AppSource site
// NOTE: the temp object is needed here. While JObject.Parse looks like a static call
// to the JObject type, it will in fact reload and modify the underlying referenced object
// as well as return the result of a 'parse'
TempObject := TempObject.Parse(GetValue(JObject, 'data', false));
exit(GetValue(TempObject, 'responseUrl', false));
end;
[TryFunction]
local procedure TryMakeMarketplaceTelemetryCallback(ResponseURL: Text; InstallationResult: Option UserNotAuthorized,DeploymentFailedDueToPackage,DeploymentFailed,Successful,UserCancel,UserTimeOut)
var
TempBlob: Codeunit "Temp Blob";
HttpWebResponse: DotNet HttpWebResponse;
ResponseInStream: InStream;
begin
InitializeHTTPRequest(ResponseURL);
HttpWebRequest.Accept := '*/*';
HttpWebRequest.ContentType := 'application/json';
HttpWebRequest.Method := 'POST';
AddBodyAsText(Format(InstallationResult));
TempBlob.CreateInStream(ResponseInStream);
ClearLastError();
HttpWebResponse := HttpWebRequest.GetResponse();
HttpWebResponse.GetResponseStream().CopyTo(ResponseInStream);
end;
[TryFunction]
local procedure TryParseMarketplaceApplicationId(MarketplaceApplicationId: Text; ExpectedToken: Option PUBID,AID,PAPPID; var GlobalId: Text)
var
actualToken: Text;
TokenFound: Boolean;
CurrentToken: Text;
begin
// Extract token value from format: PUBID.<value>|AID.<value>|PAPPID.<app id>{|-preview}
// Since 'split' in AL depends on comma delimiters, make sure we remove existing commas
GlobalId := ConvertStr(MarketplaceApplicationId, ',', ';');
// Create 'split' points at pipes
GlobalId := ConvertStr(GlobalId, '|', ',');
// Flag to indicate if expected token found or not
TokenFound := false;
// Iterate over tokens
while GlobalId <> '' do begin
CurrentToken := SelectStr(1, GlobalId);
// Remove the scanned token from GlobalID
GlobalId := DelStr(GlobalId, 1, StrLen(CurrentToken) + 1);
// Create 'split' point at token\value separator
CurrentToken := ConvertStr(CurrentToken, '.', ',');
// Get token
actualToken := SelectStr(1, CurrentToken);
if actualToken = Format(ExpectedToken) then begin
TokenFound := true;
break;
end;
end;
if not TokenFound then
Error(ParseApplicationIdErr, ExpectedToken);
// Select the value of the token
GlobalId := SelectStr(2, CurrentToken);
end;
procedure MapMarketplaceIdToAppId(MarketplaceApplicationId: Text): Guid
var
GlobalId: Text;
NullGuid: Guid;
begin
// When an ISV submits an Extension to AppSource for publication to
// the marketplace, their artifact (.app) is associated with an
// ID created internally by the AppSource team.
// The .app and other associated data are then submitted to our
// Certification/Validation service. The id created by AppSource for
// this item is included as part of this payload. Unfortunately,
// the id isn't provided to our service using the same name: 'applicationId'.
// It is currently not known what name is used during this initial
// submission, but once known it will be our responsibility to create
// a mapping path between that service, the extension, and this codeunit.
// Format:
// PUBID.<value>|AID.<value>|PAPPID.<package id>{|-preview}
if TryParseMarketplaceApplicationId(MarketplaceApplicationId, Token::PAPPID, GlobalId) then
exit(GlobalId);
exit(NullGuid);
end;
procedure MakeMarketplaceTelemetryCallback(ResponseURL: Text; InstallationResult: Option UserNotAuthorized,DeploymentFailedDueToPackage,DeploymentFailed,Successful,UserCancel,UserTimeOut)
begin
if not TryMakeMarketplaceTelemetryCallback(ResponseURL, InstallationResult) then
if InstallationResult = OperationResult::Successful then
Session.LogMessage('00008LZ', MarketPlaceSuccInstallTxt, Verbosity::Normal, DataClassification::CustomerContent, TelemetryScope::ExtensionPublisher, 'Category', 'Extensions')
else
Session.LogMessage('00008M0', StrSubstNo(MarketPlaceUnsuccInstallTxt, InstallationResult, GetLastErrorText()), Verbosity::Warning, DataClassification::CustomerContent, TelemetryScope::ExtensionPublisher, 'Category', 'AL Extensions');
end;
procedure InstallMarketplaceExtension(ApplicationId: Guid; ResponseURL: Text; lcid: Integer; PreviewKey: Text)
var
PublishedApplication: Record "Published Application";
ExtensionInstallationImpl: Codeunit "Extension Installation Impl";
ExtensionOperationImpl: Codeunit "Extension Operation Impl";
begin
if IsNullGuid(ApplicationId) then
Error(TelemetryExtensionNotFoundErr, ApplicationId);
if PreviewKey <> '' then begin
// Preview keys are not saved on the data plane and we must then trigger the full deploy operation.
ExtensionOperationImpl.DeployExtension(ApplicationId, lcid, true, PreviewKey);
exit;
end;
PublishedApplication.SetRange("Package ID", ExtensionOperationImpl.GetLatestVersionPackageIdByAppId(ApplicationId));
PublishedApplication.SetRange("Tenant Visible", true);
if not PublishedApplication.FindFirst() then begin
// If the extension is not found, send the request to the regional service.
ExtensionOperationImpl.DeployExtension(ApplicationId, lcid, true, '');
exit;
end;
// Check if the extension is already installed
if ExtensionInstallationImpl.IsInstalledByAppId(ApplicationId) then begin
Message(StrSubstNo(AlreadyInstalledMsg, PublishedApplication.Name));
exit;
end;
// If it is a first party extension, install it locally
if IsFirstPartyExtension(PublishedApplication) then
InstallApp(PublishedApplication."Package ID", PublishedApplication.ID, ResponseURL, lcid)
else
// If the extension is found and it's from a third party, then send the request to regional service.
ExtensionOperationImpl.DeployExtension(ApplicationId, lcid, true, '');
end;
[TryFunction]
procedure InstallAppsourceExtension(MarketplaceApplicationId: Text; TelemetryURL: Text);
var
ExtensionInstallationRecord: Record "Extension Installation";
ExtensionInstallationPage: Page "Extension Installation";
AppId: Guid;
begin
AppId := MapMarketplaceIdToAppId(MarketplaceApplicationId);
if IsNullGuid(AppId) then begin
Session.LogMessage('0088BQQ', StrSubstNo(MissingAppIdErr, MarketplaceApplicationId), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', TelemetryTok);
Error(ExtensionNotFoundErr);
end;
ExtensionInstallationRecord.SetRange(ID, AppId);
ExtensionInstallationRecord.ID := AppId;
ExtensionInstallationRecord.ResponseUrl := CopyStr(TelemetryURL, 1, MaxStrLen(ExtensionInstallationRecord.ResponseUrl));
ExtensionInstallationPage.SetRecord(ExtensionInstallationRecord);
ExtensionInstallationPage.RunModal();
end;
[TryFunction]
procedure InstallAppsourceExtension(AppId: Guid; TelemetryURL: Text)
var
ExtensionInstallationRecord: Record "Extension Installation";
ExtensionInstallationPage: Page "Extension Installation";
begin
if IsNullGuid(AppId) then begin
Session.LogMessage('0000I4S', StrSubstNo(MissingAppIdErr, AppId), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', TelemetryTok);
Error(ExtensionNotFoundErr);
end;
ExtensionInstallationRecord.SetRange(ID, AppId);
ExtensionInstallationRecord.ID := AppId;
ExtensionInstallationRecord.ResponseUrl := CopyStr(TelemetryURL, 1, MaxStrLen(ExtensionInstallationRecord.ResponseUrl));
ExtensionInstallationPage.SetRecord(ExtensionInstallationRecord);
ExtensionInstallationPage.RunModal();
end;
procedure InstallAppsourceExtensionWithRefreshSession(MarketplaceApplicationID: Text; TelemetryURL: Text);
var
ExtensionPendingSetup: Record "Extension Pending Setup";
ExtensionInstallationImpl: Codeunit "Extension Installation Impl";
MySessionSettings: SessionSettings;
AppId: Guid;
begin
ExtensionInstallationImpl.CheckPermissions();
if not InstallAppsourceExtension(MarketplaceApplicationID, TelemetryURL) then begin // successful installation returns false
AppId := MapMarketplaceIdToAppId(MarketplaceApplicationID);
if ExtensionInstallationImpl.IsInstalledByAppId(AppId) then begin
SaveExtensionPendingSetup(AppId);
MySessionSettings.Init();
MySessionSettings.RequestSessionUpdate(false);
end else begin
ExtensionPendingSetup.SetRange("User Id", UserSecurityId());
ExtensionPendingSetup.DeleteAll();
end;
end;
end;
procedure InstallAppsourceExtensionWithRefreshSession(AppId: Guid; TelemetryURL: Text);
var
ExtensionPendingSetup: Record "Extension Pending Setup";
ExtensionInstallationImpl: Codeunit "Extension Installation Impl";
MySessionSettings: SessionSettings;
begin
ExtensionInstallationImpl.CheckPermissions();
if not InstallAppsourceExtension(AppId, TelemetryURL) then // successful installation returns false
if ExtensionInstallationImpl.IsInstalledByAppId(AppId) then begin
SaveExtensionPendingSetup(AppId);
MySessionSettings.Init();
MySessionSettings.RequestSessionUpdate(false);
end else begin
ExtensionPendingSetup.SetRange("User Id", UserSecurityId());
ExtensionPendingSetup.DeleteAll();
end;
end;
[TryFunction]
procedure InstallExtension(MarketplaceApplicationID: Text; ResponseURL: Text)
var
MarketplaceExtnDeployment: Page "Marketplace Extn Deployment";
ID: Guid;
begin
ID := MapMarketplaceIdToAppId(MarketplaceApplicationID);
MarketplaceExtnDeployment.SetAppID(ID);
MarketplaceExtnDeployment.RunModal();
if MarketplaceExtnDeployment.GetInstalledSelected() then
InstallMarketplaceExtension(ID, ResponseURL, MarketplaceExtnDeployment.GetLanguageId(), '');
end;
procedure IsMarketplaceEnabled(): Boolean
var
AzureKeyVault: Codeunit "Azure Key Vault";
DisabledSecret: Text;
DisabledValue: Boolean;
begin
// Try to retrieve config value from keyvault, but if we fail (not there, or not boolean) then assume true
if AzureKeyVault.GetAzureKeyVaultSecret(MarketplaceDisabledSecretTxt, DisabledSecret) then
if Evaluate(DisabledValue, DisabledSecret) then
exit(not DisabledValue);
exit(true);
end;
procedure InitializeHTTPRequest(URL: Text)
var
EnvironmentInformation: Codeunit "Environment Information";
begin
if not EnvironmentInformation.IsSaaS() then
OnOverrideUrl(URL);
HttpWebRequest := HttpWebRequest.Create(URL);
SetHttpRequestDefaults();
end;
local procedure SetHttpRequestDefaults()
var
CookieContainer: DotNet CookieContainer;
begin
HttpWebRequest.KeepAlive := true;
HttpWebRequest.AllowAutoRedirect := true;
HttpWebRequest.UseDefaultCredentials := true;
HttpWebRequest.Timeout := 60000;
CookieContainer := CookieContainer.CookieContainer();
HttpWebRequest.CookieContainer := CookieContainer;
end;
local procedure AddBodyAsText(InstallationResult: Text)
var
RequestStr: DotNet Stream;
StreamWriter: DotNet StreamWriter;
Encoding: DotNet Encoding;
BodyText: Text;
begin
// BodyText is an enum string that AppSource recognizes, as defined by the OperationResult.
BodyText := StrSubstNo(TelemetryBodyTxt, InstallationResult, 'ExtensionInstallation');
RequestStr := HttpWebRequest.GetRequestStream();
StreamWriter := StreamWriter.StreamWriter(RequestStr, Encoding);
StreamWriter.Write(BodyText);
StreamWriter.Flush();
StreamWriter.Close();
StreamWriter.Dispose();
end;
local procedure InstallApp(PackageId: Guid; ResponseURL: Text; lcid: Integer): Boolean
var
ExtensionInstallationImpl: Codeunit "Extension Installation Impl";
HasSucceeded: Boolean;
begin
HasSucceeded := ExtensionInstallationImpl.InstallExtensionWithConfirmDialog(PackageId, lcid);
if HasSucceeded = true then
MakeMarketplaceTelemetryCallback(ResponseURL, OperationResult::Successful)
else
MakeMarketplaceTelemetryCallback(ResponseURL, OperationResult::DeploymentFailedDueToPackage);
exit(HasSucceeded);
end;
local procedure InstallApp(PackageId: Guid; AppId: Guid; ResponseURL: Text; lcid: Integer)
begin
if not InstallApp(PackageId, ResponseURL, lcid) then
exit;
RunSetupForExtension(AppId);
end;
procedure RunSetupForExtension(AppId: Guid)
var
GuidedExperience: Codeunit "Guided Experience";
begin
if not GuidedExperience.SetupForExtensionExists(AppId) then begin
Message(AppDoesntNeedSetupMsg);
exit;
end;
GuidedExperience.RunExtensionSetup(AppId);
end;
procedure RunPendingSetup()
var
ExtensionPendingSetup: Record "Extension Pending Setup";
AppToSetup: Guid;
begin
ExtensionPendingSetup.SetRange("User Id", UserSecurityId());
ExtensionPendingSetup.SetRange("Created On", CurrentDateTime - (5 * 60000), CurrentDateTime); // created in the last 5 minutes
if ExtensionPendingSetup.FindFirst() then begin
AppToSetup := ExtensionPendingSetup."App Id";
ExtensionPendingSetup.SetRange("Created On");
ExtensionPendingSetup.DeleteAll();
RunSetupForExtension(AppToSetup);
end else begin
ExtensionPendingSetup.SetRange("Created On");
ExtensionPendingSetup.DeleteAll();
end;
end;
local procedure SaveExtensionPendingSetup(AppIdToSave: Guid)
var
ExtensionPendingSetup: Record "Extension Pending Setup";
ExtensionSetupLauncher: Page "Extension Setup Launcher";
begin
ExtensionPendingSetup.SetRange("User Id", UserSecurityId());
ExtensionPendingSetup.DeleteAll();
Clear(ExtensionPendingSetup);
ExtensionPendingSetup."User Id" := UserSecurityId();
ExtensionPendingSetup."Created On" := CurrentDateTime();
ExtensionPendingSetup."App Id" := AppIdToSave;
if ExtensionPendingSetup.Insert() then;
Commit();
ExtensionSetupLauncher.SetNoSetupOnOpen(true);
ExtensionSetupLauncher.Run();
end;
procedure SetupForExtensionExists(AppId: Guid): Boolean
var
GuidedExperience: Codeunit "Guided Experience";
begin
exit(GuidedExperience.SetupForExtensionExists(AppId));
end;
local procedure IsFirstPartyExtension(PublishedApplication: Record "Published Application"): Boolean
begin
if PublishedApplication.Publisher = 'Microsoft' then
exit(true);
exit(false);
end;
procedure GetMarketplaceEmbeddedUrl(): Text;
begin
exit(AppsourceTxt + EmbedRelativeTxt);
end;
procedure GetMessageType(JObject: DotNet JObject): Text;
begin
// Extracts the 'msgType' property from the
exit(GetValue(JObject, 'msgType', true));
end;
procedure GetApplicationIdFromData(JObject: DotNet JObject): Text;
var
TempObject: DotNet JObject;
begin
// Extracts the applicationId property out of the data object return by the SPZA site
TempObject := TempObject.Parse(GetValue(JObject, 'data', true));
exit(GetValue(TempObject, 'applicationId', true));
end;
[EventSubscriber(ObjectType::Codeunit, Codeunit::"System Action Triggers", InvokeExtensionInstallation, '', false, false)]
local procedure InvokeExtensionInstallation(AppId: Text; ResponseUrl: Text)
begin
if not InstallExtension(AppId, ResponseUrl) then
Message(GetLastErrorText());
end;
[InternalEvent(false)]
internal procedure OnOverrideUrl(var Url: Text)
begin
// Provides an option to rewrite URL in non SaaS environments.
end;
[EventSubscriber(ObjectType::Codeunit, Codeunit::"System Action Triggers", OpenAppSourceMarket, '', false, false)]
local procedure OpenAppSourceMarket()
begin
#if not CLEAN24
#pragma warning disable AL0432
Page.Run(Page::"Extension Marketplace");
#pragma warning restore AL0432
#else
Hyperlink('https://aka.ms/BCAppSource');
#endif
end;
}