diff --git a/documentation/Export-PnPPowerApp.md b/documentation/Export-PnPPowerApp.md new file mode 100644 index 000000000..3a6d66996 --- /dev/null +++ b/documentation/Export-PnPPowerApp.md @@ -0,0 +1,198 @@ +--- +Module Name: PnP.PowerShell +schema: 2.0.0 +applicable: SharePoint Online +online version: https://pnp.github.io/powershell/cmdlets/Export-PnPPowerApp.html +external help file: PnP.PowerShell.dll-Help.xml +title: Export-PnPPowerApp +--- + +# Export-PnPPowerApp + +## SYNOPSIS + +**Required Permissions** + +* Azure: management.azure.com + +Exports a Microsoft Power App + +## SYNTAX + +``` +Export-PnPPowerApp -Environment -Identity + [-PackageDisplayName ] [-PackageDescription ] [-PackageCreatedBy ] + [-PackageSourceEnvironment ] [-OutPath ] [-Force] [-Connection ] + [] +``` + +## DESCRIPTION +This cmdlet exports a Microsoft Power App as zip package. + +Many times exporting a Microsoft Power App will not be possible due to various reasons such as connections having gone stale, SharePoint sites referenced no longer existing or other configuration errors in the App. To display these errors when trying to export a App, provide the -Verbose flag with your export request. If not provided, these errors will silently be ignored. + +## EXAMPLES + +### Example 1 +```powershell +$environment = Get-PnPPowerPlatformEnvironment -IsDefault $true +Export-PnPPowerApp -Environment $environment -Identity fba63225-baf9-4d76-86a1-1b42c917a182 -OutPath "C:\Users\user1\Downloads\test_20230408152624.zip" +``` + +This will export the specified Microsoft Power App from the default Power Platform environment as an output to the path specified in the command as -OutPath + +### Example 2 +```powershell +$environment = Get-PnPPowerPlatformEnvironment -IsDefault $true +Export-PnPPowerApp -Environment $environment -Identity fba63225-baf9-4d76-86a1-1b42c917a182 -OutPath "C:\Users\user1\Downloads\test_20230408152624.zip" -PackageDisplayName "MyAppDisplayName" -PackageDescription "Package exported using PnP Powershell" -PackageCreatedBy "Siddharth Vaghasia" -PackageSourceEnvironment "UAT Environment" +``` +This will export the specified Microsoft Power App from the default Power Platform environment with metadata specified + +### Example 3 +```powershell +Get-PnPPowerPlatformEnvironment | foreach { Get-PnPPowerApp -Environment $_.Name } | foreach { Export-PnPPowerApp -Environment $_.Properties.EnvironmentDetails.Name -Identity $_ -OutPath "C:\Users\user1\Downloads\$($_.Name).zip" } +``` + +This will export all the Microsoft Power Apps available within the tenant from all users from all the available Power Platform environments as a ZIP package for each of them to a local folder C:\Users\user1\Downloads + +## PARAMETERS + +### -Connection +Optional connection to be used by the cmdlet. +Retrieve the value for this parameter by either specifying -ReturnConnection on Connect-PnPOnline or by executing Get-PnPConnection. + +```yaml +Type: PnPConnection +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Environment +The environment which contains the App. + +```yaml +Type: PowerPlatformEnvironmentPipeBind +Parameter Sets: (All) +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Identity +The value of the Name property of a Microsoft Power App that you wish to export + +```yaml +Type: PowerAppPipeBind +Parameter Sets: (All) +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Force +If specified and the file exported already exists it will be overwritten without confirmation. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -OutPath +Optional file name of the file to export to. If not provided, it will store the ZIP package to the current location from where the cmdlet is being run. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PackageCreatedBy +The name of the person to be used as the creator of the exported package + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PackageDescription +The description to use in the exported package + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PackageDisplayName +The display name to use in the exported package + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PackageSourceEnvironment +The name of the source environment from which the exported package was taken + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +## RELATED LINKS + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) \ No newline at end of file diff --git a/src/Commands/Model/PowerPlatform/PowerApp/Enums/PowerAppExportStatus.cs b/src/Commands/Model/PowerPlatform/PowerApp/Enums/PowerAppExportStatus.cs new file mode 100644 index 000000000..16ba71a3f --- /dev/null +++ b/src/Commands/Model/PowerPlatform/PowerApp/Enums/PowerAppExportStatus.cs @@ -0,0 +1,23 @@ +namespace PnP.PowerShell.Commands.Model.PowerPlatform.PowerApp.Enums +{ + /// + /// Contains the possible states of a App export request + /// + public enum PowerAppExportStatus + { + /// + /// PowerApp export failed + /// + Failed, + + /// + /// PowerApp exported successfully + /// + Succeeded, + + /// + /// Export in progress + /// + Running + } +} \ No newline at end of file diff --git a/src/Commands/Model/PowerPlatform/PowerApp/PowerAppExportPackageError.cs b/src/Commands/Model/PowerPlatform/PowerApp/PowerAppExportPackageError.cs new file mode 100644 index 000000000..c8bdd4842 --- /dev/null +++ b/src/Commands/Model/PowerPlatform/PowerApp/PowerAppExportPackageError.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace PnP.PowerShell.Commands.Model.PowerPlatform.PowerApp +{ + /// + /// Contains the error details when requesting an export of a Flow package + /// + public class PowerAppExportPackageError + { + /// + /// Error code + /// + [JsonPropertyName("code")] + public string Code { get; set; } + + /// + /// Description of the error + /// + [JsonPropertyName("message")] + public string Message { get; set; } + } +} \ No newline at end of file diff --git a/src/Commands/Model/PowerPlatform/PowerApp/PowerAppExportPackageResource.cs b/src/Commands/Model/PowerPlatform/PowerApp/PowerAppExportPackageResource.cs new file mode 100644 index 000000000..689269898 --- /dev/null +++ b/src/Commands/Model/PowerPlatform/PowerApp/PowerAppExportPackageResource.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace PnP.PowerShell.Commands.Model.PowerPlatform.PowerApp +{ + /// + /// Contains details on a specific resource in a Flow export request + /// + public class PowerAppExportPackageResource + { + /// + /// Full identifier path of the resource + /// + [JsonPropertyName("id")] + public string Id { get; set; } + + /// + /// Identifier of the resource + /// + [JsonPropertyName("name")] + public string Name { get; set; } + + /// + /// Type of resource + /// + [JsonPropertyName("type")] + public string Type { get; set; } + + /// + /// Indicator if the resource should be updated or considered a new resource by default on import + /// + [JsonPropertyName("creationType")] + public string CreationType { get; set; } + + /// + /// Additional details on the resource + /// + [JsonPropertyName("details")] + public Dictionary Details { get; set; } + + /// + /// Indicator who can configure the resource + /// + [JsonPropertyName("configurableBy")] + public string ConfigurableBy { get; set; } + + /// + /// Indicator where this resource is located in a hierarchical structure + /// + [JsonPropertyName("hierarchy")] + public string Hierarchy { get; set; } + + /// + /// Indicator if there are dependencies on other resources + /// + [JsonPropertyName("dependsOn")] + public object[] DependsOn { get; set; } + + /// + /// Suggested approach on import + /// + [JsonPropertyName("suggestedCreationType")] + public string SuggestedCreationType { get; set; } + } +} \ No newline at end of file diff --git a/src/Commands/Model/PowerPlatform/PowerApp/PowerAppPackageWrapper.cs b/src/Commands/Model/PowerPlatform/PowerApp/PowerAppPackageWrapper.cs new file mode 100644 index 000000000..90676d6f2 --- /dev/null +++ b/src/Commands/Model/PowerPlatform/PowerApp/PowerAppPackageWrapper.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace PnP.PowerShell.Commands.Model.PowerPlatform.PowerApp +{ + /// + /// Contains the results of a request to export a Flow package + /// + public class PowerAppPackageWrapper + { + /// + /// Raw state indicating if the Flow export request was successful + /// + [JsonPropertyName("status")] + public string StatusRaw { get; set; } + + /// + /// The status of the export request as an enum + /// + [JsonIgnore] + public Enums.PowerAppExportStatus? Status + { + get { return !string.IsNullOrWhiteSpace(StatusRaw) && Enum.TryParse(StatusRaw, true, out var result) ? result : (Enums.PowerAppExportStatus?)null; } + set { StatusRaw = value.ToString(); } + } + + /// + /// Contains the resource identifiers + /// + [JsonPropertyName("baseResourceIds")] + public string[] BaseResourceIds { get; set; } + + /// + /// List with resources contained in the export + /// + [JsonPropertyName("resources")] + public Dictionary Resources { get; set; } + + /// + /// Array with errors generated when trying to export the resource + /// + [JsonPropertyName("errors")] + public PowerAppExportPackageError[] Errors{ get; set; } + } +} \ No newline at end of file diff --git a/src/Commands/PowerPlatform/PowerApps/ExportPowerApp.cs b/src/Commands/PowerPlatform/PowerApps/ExportPowerApp.cs new file mode 100644 index 000000000..1f47dd593 --- /dev/null +++ b/src/Commands/PowerPlatform/PowerApps/ExportPowerApp.cs @@ -0,0 +1,125 @@ +using PnP.PowerShell.Commands.Attributes; +using PnP.PowerShell.Commands.Base; +using PnP.PowerShell.Commands.Base.PipeBinds; +using PnP.PowerShell.Commands.Utilities.REST; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Management.Automation; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using PnP.PowerShell.Commands.Utilities; + +namespace PnP.PowerShell.Commands.PowerPlatform.PowerApps +{ + [Cmdlet(VerbsData.Export, "PnPPowerApp")] + [RequiredMinimalApiPermissions("https://management.azure.com/.default")] + public class ExportPowerApp : PnPAzureManagementApiCmdlet + { + + [Parameter(Mandatory = true)] + + public PowerPlatformEnvironmentPipeBind Environment; + + [Parameter(Mandatory = true)] + public PowerAppPipeBind Identity; + + [Parameter(Mandatory = false)] + public string PackageDisplayName; + + [Parameter(Mandatory = false)] + public string PackageDescription; + + [Parameter(Mandatory = false)] + public string PackageCreatedBy; + + [Parameter(Mandatory = false)] + public string PackageSourceEnvironment; + + [Parameter(Mandatory = false)] + public string OutPath; + + [Parameter(Mandatory = false)] + public SwitchParameter Force; + + protected override void ExecuteCmdlet() + { + if (ParameterSpecified(nameof(OutPath))) + { + if (!System.IO.Path.IsPathRooted(OutPath)) + { + OutPath = System.IO.Path.Combine(SessionState.Path.CurrentFileSystemLocation.Path, OutPath); + } + if (System.IO.Directory.Exists(OutPath)) + { + throw new PSArgumentException("Please specify a folder including a filename"); + } + if (System.IO.File.Exists(OutPath)) + { + if (!Force && !ShouldContinue($"File '{OutPath}' exists. Overwrite?", "Export App")) + { + // Exit cmdlet + return; + } + } + } + + var environmentName = Environment.GetName(); + var appName = Identity.GetName(); + + var wrapper = PowerAppsUtility.GetWrapper(Connection.HttpClient, environmentName, AccessToken, appName).GetAwaiter().GetResult(); + + if (wrapper.Status == Model.PowerPlatform.PowerApp.Enums.PowerAppExportStatus.Succeeded) + { + foreach (var resource in wrapper.Resources) + { + if (resource.Value.Type == "Microsoft.PowerApps/apps") + { + resource.Value.SuggestedCreationType = "Update"; + } + } + var objectDetails = new { + displayName = PackageDisplayName, + description = PackageDescription, + creator = PackageCreatedBy, + sourceEnvironment = PackageSourceEnvironment + }; + var responseHeader = PowerAppsUtility.GetResponseHeader(Connection.HttpClient, environmentName,AccessToken, appName, wrapper, objectDetails); + + + var packageLink = PowerAppsUtility.GetPackageLink(Connection.HttpClient,Convert.ToString(responseHeader.Location),AccessToken); + var getFileByteArray = PowerAppsUtility.GetFileByteArray(Connection.HttpClient, packageLink, AccessToken); + var fileName = string.Empty; + if (ParameterSpecified(nameof(OutPath))) + { + if (!System.IO.Path.IsPathRooted(OutPath)) + { + OutPath = System.IO.Path.Combine(SessionState.Path.CurrentFileSystemLocation.Path, OutPath); + } + fileName = OutPath; + } + else + { + fileName = new System.Text.RegularExpressions.Regex("([^\\/]+\\.zip)").Match(packageLink).Value; + fileName = System.IO.Path.Combine(SessionState.Path.CurrentFileSystemLocation.Path, fileName); + } + + System.IO.File.WriteAllBytes(fileName, getFileByteArray); + var returnObject = new PSObject(); + returnObject.Properties.Add(new PSNoteProperty("Filename", fileName)); + WriteObject(returnObject); + } + else + { + // Errors have been reported in the export request result + foreach (var error in wrapper.Errors) + { + WriteVerbose($"Export failed for {appName} with error {error.Code}: {error.Message}"); + } + } + } + + } +} diff --git a/src/Commands/Utilities/PowerAppsUtility.cs b/src/Commands/Utilities/PowerAppsUtility.cs new file mode 100644 index 000000000..773a97a48 --- /dev/null +++ b/src/Commands/Utilities/PowerAppsUtility.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using PnP.PowerShell.Commands.Base; +using PnP.PowerShell.Commands.Utilities.REST; + +namespace PnP.PowerShell.Commands.Utilities +{ + internal static class PowerAppsUtility + { + internal static async Task GetWrapper(HttpClient connection, string environmentName, string accessToken, string appName) + { + var postData = new + { + baseResourceIds = new[] { + $"/providers/Microsoft.PowerApps/apps/{appName}" + } + }; + + var wrapper = await RestHelper.PostAsync(connection, $"https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments/{environmentName}/listPackageResources?api-version=2016-11-01", accessToken, payload: postData); + + + return wrapper; + } + + internal static HttpResponseHeaders GetResponseHeader(HttpClient connection, string environmentName, string accessToken, string appName,Model.PowerPlatform.PowerApp.PowerAppPackageWrapper wrapper, object details) + { + var exportPostData = new + { + includedResourceIds = new[] + { + $"/providers/Microsoft.PowerApps/apps/{appName}" + }, + details = details, + resources = wrapper.Resources + }; + + var responseHeader = RestHelper.PostAsyncGetResponseHeader(connection, $"https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments/{environmentName}/exportPackage?api-version=2016-11-01", accessToken, payload: exportPostData).GetAwaiter().GetResult(); + + + return responseHeader; + } + + internal static string GetPackageLink(HttpClient connection,string location,string accessToken) + { + var status = Model.PowerPlatform.PowerApp.Enums.PowerAppExportStatus.Running; + var packageLink = ""; + if (location != null) + { + do + { + var runningresponse = RestHelper.GetAsync(connection, location, accessToken).GetAwaiter().GetResult(); + + if (runningresponse.TryGetProperty("properties", out JsonElement properties)) + { + if (properties.TryGetProperty("status", out JsonElement runningstatusElement)) + { + if (runningstatusElement.GetString() == Model.PowerPlatform.PowerApp.Enums.PowerAppExportStatus.Succeeded.ToString()) + { + status = Model.PowerPlatform.PowerApp.Enums.PowerAppExportStatus.Succeeded; + if (properties.TryGetProperty("packageLink", out JsonElement packageLinkElement)) + { + if (packageLinkElement.TryGetProperty("value", out JsonElement valueElement)) + { + packageLink = valueElement.GetString(); + } + } + } + else + { + //if status is still running, sleep the thread for 3 seconds + Thread.Sleep(3000); + } + } + } + } while (status == Model.PowerPlatform.PowerApp.Enums.PowerAppExportStatus.Running); + } + return packageLink; + } + + internal static byte[] GetFileByteArray(HttpClient connection, string packageLink, string accessToken) + { + using (var requestMessage = new HttpRequestMessage(HttpMethod.Get, packageLink)) + { + requestMessage.Version = new Version(2, 0); + //requestMessage.Headers.Add("Authorization", $"Bearer {AccessToken}"); + var fileresponse = connection.SendAsync(requestMessage).GetAwaiter().GetResult(); + var byteArray = fileresponse.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + return byteArray; + } + + } + } +} diff --git a/src/Commands/Utilities/REST/RestHelper.cs b/src/Commands/Utilities/REST/RestHelper.cs index 4513739b9..b159e78df 100644 --- a/src/Commands/Utilities/REST/RestHelper.cs +++ b/src/Commands/Utilities/REST/RestHelper.cs @@ -289,6 +289,22 @@ public static async Task PostAsync(HttpClient httpClient, string url, stri return default(T); } + public static async Task PostAsyncGetResponseHeader(HttpClient httpClient, string url, string accessToken, object payload, bool camlCasePolicy = true, string accept = "application/json") + { + HttpRequestMessage message = null; + if (payload != null) + { + var content = new StringContent(JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull })); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + message = GetMessage(url, HttpMethod.Post, accessToken, accept, content); + } + else + { + message = GetMessage(url, HttpMethod.Post, accessToken, accept); + } + return await SendMessageAsyncGetResponseHeader(httpClient, message); + } + public static async Task PostAsync(HttpClient httpClient, string url, ClientContext clientContext, object payload, bool camlCasePolicy = true) @@ -633,6 +649,28 @@ private static async Task SendMessageAsync(HttpClient httpClient, HttpRe } } + private static async Task SendMessageAsyncGetResponseHeader(HttpClient httpClient, HttpRequestMessage message) + { + var response = await httpClient.SendAsync(message); + while (response.StatusCode == (HttpStatusCode)429) + { + // throttled + var retryAfter = response.Headers.RetryAfter; + await Task.Delay(retryAfter.Delta.Value.Seconds * 1000); + response = await httpClient.SendAsync(CloneMessage(message)); + } + if (response.IsSuccessStatusCode) + { + return response.Headers; + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new HttpRequestException(errorContent); + } + } + + private static HttpRequestMessage CloneMessage(HttpRequestMessage req) { HttpRequestMessage clone = new HttpRequestMessage(req.Method, req.RequestUri);