Codeunit 2515 AppSource Product Manager, source in 28

Source242526272829metadata in 28

src/System Application/App/AppSource Gallery/src/AppSourceProductManager.Codeunit.al397 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.AppSource;

using System.Azure.Identity;
using System.Azure.KeyVault;
using System.Environment;
using System.Environment.Configuration;
using System.Globalization;
using System.RestClient;
using System.Utilities;

/// <summary>
/// Library for managing AppSource product retrieval and usage.
/// </summary>
codeunit 2515 "AppSource Product Manager"
{
    Access = Internal;
    InherentEntitlements = X;
    InherentPermissions = X;

    var
        AppSourceJsonUtilities: Codeunit "AppSource Json Utilities";
        AppSourceProductManagerDependencies: Interface "AppSource Product Manager Dependencies";
        IsDependenciesInterfaceSet: Boolean;
        CatalogProductsUriLbl: Label 'https://catalogapi.azure.com/products', Locked = true;
        CatalogApiVersionQueryParamNameLbl: Label 'api-version', Locked = true;
        CatalogApiVersionQueryParamValueLbl: Label '2023-05-01-preview', Locked = true;
        CatalogApiVersionOldQueryParamValueLbl: Label '2018-08-01-beta', Locked = true;
        CatalogApiOrderByQueryParamNameLbl: Label '$orderby', Locked = true;
        CatalogMarketQueryParamNameLbl: Label 'market', Locked = true;
        CatalogListMarketNameLbl: label 'all', Locked = true;
        CatalogLanguageQueryParamNameLbl: Label 'language', Locked = true;
        CatalogApiFilterQueryParamNameLbl: Label '$filter', Locked = true;
        CatalogApiSelectQueryParamNameLbl: Label '$select', Locked = true;
        AppSourceListingUriLbl: Label 'https://appsource.microsoft.com/%1/product/dynamics-365-business-central/%2', Comment = '%1=Language ID, such as en-US, %2=Url Query Content', Locked = true;
        AppSourceUriLbl: Label 'https://appsource.microsoft.com/%1/marketplace/apps?product=dynamics-365-business-central', Comment = '1%=Language ID, such as en-US', Locked = true;
        NotSupportedOnPremisesErrorLbl: Label 'Not supported on premises.';
        UnsupportedLanguageNotificationLbl: Label 'Language %1 is not supported by AppSource. Defaulting to "en". Change the language in the user profile to use another language.', Comment = '%1=Language ID, such as en';

    #region Product helpers
    /// <summary>
    /// Opens Microsoft AppSource web page for the region is specified in the UserSessionSettings or 'en-us' by default.
    /// </summary>
    procedure OpenAppSource()
    begin
        Init();
        Hyperlink(StrSubstNo(AppSourceUriLbl, AppSourceProductManagerDependencies.GetFormatRegionOrDefault('')));
    end;

    /// <summary>
    /// Opens the AppSource product page in Microsoft AppSource, for the specified unique product ID.
    /// </summary>
    /// <param name="UniqueProductIDValue">The Unique Product ID of the product to show in MicrosoftAppSource</param>
    procedure OpenAppInAppSource(UniqueProductIDValue: Text)
    begin
        Init();
        Hyperlink(StrSubstNo(AppSourceListingUriLbl, GetCurrentLanguageCultureName(), UniqueProductIDValue));
    end;

    /// <summary>
    /// Opens the AppSource product details page for the specified unique product ID.
    /// </summary>
    /// <param name="UniqueProductIDValue"></param>
    internal procedure OpenProductDetailsPage(UniqueProductIDValue: Text)
    var
        AppSourceProductDetailsPage: Page "AppSource Product Details";
        ProductObject: JsonObject;
    begin
        ProductObject := GetProductDetails(UniqueProductIDValue);
        AppSourceProductDetailsPage.SetProduct(ProductObject);
        AppSourceProductDetailsPage.RunModal();
    end;

    /// <summary>
    /// Extracts the AppID from the Unique Product ID.
    /// </summary>
    /// <param name="UniqueProductIDValue">The Unique Product ID of the product as defined in MicrosoftAppSource</param>
    /// <returns>AppID found in the Product ID</returns>
    /// <remarks>The AppSource unique product ID is specific to AppSource and combines different features while always ending with PAPID. and extension app id. Example: PUBID.mdcc1667400477212|AID.bc_converttemp_sample|PAPPID.9d314b3e-ffd3-41fd-8755-7744a6a790df</remarks>
    internal procedure ExtractAppIDFromUniqueProductID(UniqueProductIDValue: Text): Guid
    var
        AppIDPos: Integer;
        EmptyGuid: Guid;
    begin
        AppIDPos := StrPos(UniqueProductIDValue, 'PAPPID.');
        if (AppIDPos > 0) then
            exit(CopyStr(UniqueProductIDValue, AppIDPos + 7, 36));
        exit(EmptyGuid);
    end;

    /// <summary>
    /// Checks if the product can be installed or your are required to perform operations on AppSource before you can install the product.
    /// </summary>
    internal procedure CanInstallProductWithPlans(UniqieProductIDValue: Text): Boolean
    var
        LegacyProductObject: JsonObject;
        LegacyPlansToken: JsonToken;
        LegacyPlans: JsonArray;
        PlanToken: JsonToken;
        PlanObject: JsonObject;
        CallToActionToken: JsonToken;
    begin
        Init();

        // Query legacy api toget all the plan and test if there is a contact me call to action.
        LegacyProductObject := GetProductDetails(UniqieProductIDValue, ConstructProductUri(UniqieProductIDValue, CatalogApiVersionOldQueryParamValueLbl));
        LegacyProductObject.Get('plans', LegacyPlansToken);
        LegacyPlans := LegacyPlansToken.AsArray();
        foreach PlanToken in LegacyPlans do begin
            PlanObject := PlanToken.AsObject();
            if PlanObject.Get('callToAction', CallToActionToken) then
                if LowerCase(CallToActionToken.AsValue().AsText()) = 'contactme' then
                    exit(false);
        end;

        exit(true);
    end;
    #endregion

    #region Market and language helper functions
    local procedure GetCurrentLanguageCultureName(): Text
    var
        Language: Codeunit Language;
    begin
        exit(Language.GetCultureName(GetCurrentUserLanguageID()));
    end;

    local procedure ResolveLanguageName() LanguageName: Text;
    var
        Language: Codeunit Language;
        LanguageID: Integer;
    begin
        LanguageID := GetCurrentUserLanguageAndLocaleID();

        // Marketplace API only supports two letter languages.
        LanguageName := Language.GetTwoLetterISOLanguageName(LanguageID);
        LanguageName := EnsureValidLanguage(LanguageName);
    end;

    procedure GetCurrentUserLanguageID(): Integer
    var
        TempUserSettings: Record "User Settings" temporary;
        Language: Codeunit Language;
        LanguageID: Integer;
    begin
        AppSourceProductManagerDependencies.GetUserSettings(Database.UserSecurityID(), TempUserSettings);
        LanguageID := TempUserSettings."Language ID";
        if ((LanguageID = 0) and AppSourceProductManagerDependencies.IsSaas()) then
            LanguageID := Language.GetLanguageIdFromCultureName(AppSourceProductManagerDependencies.GetPreferredLanguage());
        if (LanguageID = 0) then
            LanguageID := 1033; // Default to EN-US
        exit(LanguageID);
    end;

    local procedure GetCurrentUserLanguageAndLocaleID() LanguageID: Integer
    var
        TempUserSettings: Record "User Settings" temporary;
        Language: Codeunit Language;
    begin
        AppSourceProductManagerDependencies.GetUserSettings(Database.UserSecurityID(), TempUserSettings);
        LanguageID := TempUserSettings."Language ID";
        if ((LanguageID = 0) and AppSourceProductManagerDependencies.IsSaas()) then
            LanguageID := Language.GetLanguageIdFromCultureName(AppSourceProductManagerDependencies.GetPreferredLanguage());
        if (LanguageID = 0) then
            LanguageID := 1033; // Default to EN-US
    end;

    /// <summary>
    /// Ensures that the language is valid for AppSource.
    /// </summary>
    /// <param name="Language">Language requested</param>
    /// <returns>The requested language if supported otherwise en</returns>
    /// <remarks>See https://learn.microsoft.com/en-us/rest/api/marketplacecatalog/dataplane/products/list?view=rest-marketplacecatalog-dataplane-2023-05-01-preview&amp;tabs=HTTP for supported languages</remarks>
    local procedure EnsureValidLanguage(Language: Text): Text
    var
        NotSupportedNotification: Notification;
    begin
        case LowerCase(Language) of
            'en', 'cs', 'de', 'es', 'fr', 'hu', 'it', 'ja', 'ko', 'nl', 'pl', 'pt-br', 'pt-pt', 'ru', 'sv', 'tr', 'zh-hans', 'zh-hant':
                exit(LowerCase(Language));
            else begin
                NotSupportedNotification.Id := '0664870f-bd05-46cc-9e98-cc338d7fdc64';
                NotSupportedNotification.Message(StrSubstNo(UnsupportedLanguageNotificationLbl, Language));
                NotSupportedNotification.Send();

                exit('en');
            end;
        end;
    end;
    #endregion

    /// <summary>
    /// Get all products from a remote server and adds them to the AppSource Product table.
    /// </summary>
    internal procedure GetProductsAndPopulateRecord(var AppSourceProductRec: Record "AppSource Product"): Text
    var
        RestClient: Codeunit "Rest Client";
        NextPageLink: Text;
    begin
        Init();
        NextPageLink := ConstructProductListUri();

        RestClient.Initialize();
        if not AppSourceProductManagerDependencies.IsSaas() then
            Error(NotSupportedOnPremisesErrorLbl);

        if AppSourceProductManagerDependencies.ShouldSetCommonHeaders() then
            SetCommonHeaders(RestClient);

        repeat
            NextPageLink := DownloadAndAddNextPageProducts(NextPageLink, AppSourceProductRec, RestClient);
        until NextPageLink = '';
    end;

    /// <summary>
    /// Get specific product details from.
    /// </summary>
    local procedure GetProductDetails(UniqueProductIDValue: Text): JsonObject
    var
        RequestUri: Text;
    begin
        Init();
        RequestUri := ConstructProductUri(UniqueProductIDValue);
        exit(GetProductDetails(UniqueProductIDValue, RequestUri));
    end;

    local procedure GetProductDetails(UniqueProductIDValue: Text; RequestUri: Text): JsonObject
    var
        RestClient: Codeunit "Rest Client";
        ClientRequestID: Guid;
        TelemetryDictionary: Dictionary of [Text, Text];
    begin
        Init();
        ClientRequestID := CreateGuid();

        PopulateTelemetryDictionary(ClientRequestID, UniqueProductIDValue, RequestUri, TelemetryDictionary);
        Session.LogMessage('AL:AppSource-GetProduct', 'Requesting product details.', Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, TelemetryDictionary);

        RestClient.Initialize();
        if not AppSourceProductManagerDependencies.IsSaas() then
            Error(NotSupportedOnPremisesErrorLbl);

        if AppSourceProductManagerDependencies.ShouldSetCommonHeaders() then
            SetCommonHeaders(RestClient);

        RestClient.SetDefaultRequestHeader('client-request-id', ClientRequestID);

        exit(AppSourceProductManagerDependencies.GetAsJSon(RestClient, RequestUri).AsObject());
    end;

    local procedure DownloadAndAddNextPageProducts(NextPageLink: Text; var AppSourceProductRec: Record "AppSource Product"; var RestClient: Codeunit "Rest Client"): Text
    var
        ResponseObject: JsonObject;
        ProductArray: JsonArray;
        ProductArrayToken: JsonToken;
        ProductToken: JsonToken;
        I: Integer;
        ClientRequestID: Guid;
        TelemetryDictionary: Dictionary of [Text, Text];
    begin
        ClientRequestID := CreateGuid();
        PopulateTelemetryDictionary(ClientRequestID, '', NextPageLink, TelemetryDictionary);
        Session.LogMessage('AL:AppSource-NextPageProducts', 'Requesting product list data', Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, TelemetryDictionary);
        RestClient.SetDefaultRequestHeader('client-request-id', ClientRequestID);

        ResponseObject := AppSourceProductManagerDependencies.GetAsJSon(RestClient, NextPageLink).AsObject();
        if (ResponseObject.Get('items', ProductArrayToken)) then begin
            ProductArray := ProductArrayToken.AsArray();
            for i := 0 to (ProductArray.Count() - 1) do
                if (ProductArray.Get(i, ProductToken)) then
                    InsertProductFromObject(ProductToken.AsObject(), AppSourceProductRec);
        end;
        exit(AppSourceJsonUtilities.GetStringValue(ResponseObject, 'nextPageLink'));
    end;

    [NonDebuggable]
    internal procedure SetCommonHeaders(var RestClient: Codeunit "Rest Client")
    var
        AzureKeyVault: Codeunit "Azure Key Vault";
        AzureAdTenant: Codeunit "Azure AD Tenant";
        ApiKey: SecretText;
    begin
        AzureKeyVault.GetAzureKeyVaultSecret('MS-AppSource-ApiKey', ApiKey);

        RestClient.SetDefaultRequestHeader('X-API-Key', ApiKey);
        RestClient.SetDefaultRequestHeader('x-ms-client-tenant-id', AzureAdTenant.GetAadTenantID());
        RestClient.SetDefaultRequestHeader('x-ms-app', 'Dynamics 365 Business Central');
    end;

    local procedure ConstructProductListUri(): Text
    var
        UriBuilder: Codeunit "Uri Builder";
        Uri: Codeunit Uri;
        Language: Text;
    begin
        Language := ResolveLanguageName();

        UriBuilder.Init(CatalogProductsUriLbl);
        UriBuilder.AddQueryParameter(CatalogApiVersionQueryParamNameLbl, CatalogApiVersionQueryParamValueLbl);
        UriBuilder.AddQueryParameter(CatalogMarketQueryParamNameLbl, CatalogListMarketNameLbl);
        UriBuilder.AddQueryParameter(CatalogLanguageQueryParamNameLbl, Language);

        UriBuilder.AddODataQueryParameter(CatalogApiFilterQueryParamNameLbl, 'productType eq ''DynamicsBC''');
        UriBuilder.AddODataQueryParameter(CatalogApiSelectQueryParamNameLbl, 'uniqueProductID,displayName,publisherID,publisherDisplayName,publisherType,ratingAverage,ratingCount,productType,popularity,privacyPolicyUri,lastModifiedDateTime');
        UriBuilder.AddODataQueryParameter(CatalogApiOrderByQueryParamNameLbl, 'displayName asc');

        UriBuilder.GetUri(Uri);
        exit(Uri.GetAbsoluteUri());
    end;

    local procedure ConstructProductUri(UniqueIdentifier: Text): Text
    begin
        exit(ConstructProductUri(UniqueIdentifier, CatalogApiVersionQueryParamValueLbl));
    end;

    local procedure ConstructProductUri(UniqueIdentifier: Text; ApiVersion: Text): Text
    var
        UriBuilder: Codeunit "Uri Builder";
        Uri: Codeunit Uri;
        Language: Text;
        Market: Text;
    begin
        // For market in the product details we use the Entra ID country code.
        Market := AppSourceProductManagerDependencies.GetCountryLetterCode();
        Language := ResolveLanguageName();
        UriBuilder.Init(CatalogProductsUriLbl);
        UriBuilder.SetPath('products/' + UniqueIdentifier);
        UriBuilder.AddQueryParameter(CatalogApiVersionQueryParamNameLbl, ApiVersion);
        UriBuilder.AddQueryParameter(CatalogMarketQueryParamNameLbl, Market);
        UriBuilder.AddQueryParameter(CatalogLanguageQueryParamNameLbl, Language);
        UriBuilder.GetUri(Uri);
        exit(Uri.GetAbsoluteUri());
    end;

    #region Telemetry helpers
    local procedure PopulateTelemetryDictionary(RequestID: Text; UniqueIdentifier: Text; Uri: Text; var TelemetryDictionary: Dictionary of [Text, Text])
    begin
        PopulateTelemetryDictionary(RequestID, telemetryDictionary);
        TelemetryDictionary.Add('UniqueIdentifier', UniqueIdentifier);
        TelemetryDictionary.Add('Uri', Uri);
    end;

    local procedure PopulateTelemetryDictionary(RequestID: Text; var TelemetryDictionary: Dictionary of [Text, Text])
    begin
        TelemetryDictionary.Add('client-request-id', RequestID);
    end;
    #endregion

    local procedure InsertProductFromObject(Offer: JsonObject; var AppSourceProduct: Record "AppSource Product")
    begin
        AppSourceProduct.Init();
        AppSourceProduct.UniqueProductID := CopyStr(AppSourceJsonUtilities.GetStringValue(Offer, 'uniqueProductId'), 1, MaxStrLen(AppSourceProduct.UniqueProductID));
        AppSourceProduct.DisplayName := CopyStr(AppSourceJsonUtilities.GetStringValue(Offer, 'displayName'), 1, MaxStrLen(AppSourceProduct.DisplayName));
        AppSourceProduct.PublisherID := CopyStr(AppSourceJsonUtilities.GetStringValue(Offer, 'publisherId'), 1, MaxStrLen(AppSourceProduct.PublisherID));
        AppSourceProduct.PublisherDisplayName := CopyStr(AppSourceJsonUtilities.GetStringValue(Offer, 'publisherDisplayName'), 1, MaxStrLen(AppSourceProduct.PublisherDisplayName));
        AppSourceProduct.PublisherType := CopyStr(AppSourceJsonUtilities.GetStringValue(Offer, 'publisherType'), 1, MaxStrLen(AppSourceProduct.PublisherType));
        AppSourceProduct.RatingAverage := AppSourceJsonUtilities.GetDecimalValue(Offer, 'ratingAverage');
        AppSourceProduct.RatingCount := AppSourceJsonUtilities.GetIntegerValue(Offer, 'ratingCount');
        AppSourceProduct.ProductType := CopyStr(AppSourceJsonUtilities.GetStringValue(Offer, 'productType'), 1, MaxStrLen(AppSourceProduct.ProductType));
        AppSourceProduct.Popularity := AppSourceJsonUtilities.GetDecimalValue(Offer, 'popularity');
        AppSourceProduct.LastModifiedDateTime := AppSourceJsonUtilities.GetDateTimeValue(Offer, 'lastModifiedDateTime');

        AppSourceProduct.AppID := ExtractAppIDFromUniqueProductID(AppSourceProduct.UniqueProductID);

        // Insert, if it fails to insert due to the data (ex duplicate ids), ignore the error
        if not AppSourceProduct.Insert() then;
    end;

    local procedure Init()
    begin
        if not IsDependenciesInterfaceSet then
            SetDefaultDependencyImplementation();
    end;

    local procedure SetDefaultDependencyImplementation()
    var
        AppSrcProductDepsProvider: Codeunit "AppSrc Product Deps. Provider";
    begin
        SetDependencies(AppSrcProductDepsProvider);
    end;

    internal procedure SetDependencies(AppSourceProductManagerDependencyProvider: Interface "AppSource Product Manager Dependencies")
    begin
        AppSourceProductManagerDependencies := AppSourceProductManagerDependencyProvider;
        IsDependenciesInterfaceSet := true;
    end;

    [EventSubscriber(ObjectType::Codeunit, Codeunit::"System Action Triggers", OpenAppSourceMarket, '', false, false)]
    local procedure OpenAppSourceMarket()
    begin
        Page.Run(Page::"AppSource Product List");
    end;
}