├── tests └── PSDataverse.Tests │ ├── Usings.cs │ ├── .vscode │ ├── launch.json │ └── tasks.json │ ├── PSDataverse.Tests.csproj │ ├── AuthenticationParametersTests.cs │ ├── ConvertToCustomTextTests.cs │ └── samples │ └── DataverseEntity.sbn ├── .gitignore ├── media └── PSDataverse-Logo.png ├── src ├── Module │ ├── PSDataverse.psm1 │ ├── PSFunctions │ │ ├── Export-DataverseOptionSet.ps1 │ │ ├── Get-DataverseTableRowCount.ps1 │ │ ├── Get-DataverseAttributes.ps1 │ │ └── Clear-DataverseTable.ps1 │ └── PSDataverse.psd1 └── PSDataverse │ ├── Resources.resj │ ├── Dataverse │ ├── Execute │ │ ├── IOperationReporter.cs │ │ ├── BatchResult.cs │ │ ├── IBatchProcessor.cs │ │ ├── Processor.cs │ │ ├── OperationException.cs │ │ ├── HttpClientExtensions.cs │ │ ├── OperationHandler.cs │ │ ├── OperationProcessor.cs │ │ └── BatchProcessor.cs │ ├── ParseException.cs │ ├── ThrottlingExceededException.cs │ ├── Model │ │ ├── OperationError.cs │ │ ├── WebApiFault.cs │ │ ├── ErrorCodes.cs │ │ ├── Operation.cs │ │ ├── JsonToPSObjectConverter.cs │ │ ├── BatchResponse.cs │ │ ├── ChangeSet.cs │ │ ├── OperationResponse.cs │ │ └── Batch.cs │ ├── Commands │ │ ├── DisconnectDataverseCmdlet.cs │ │ ├── DataverseCmdlet.cs │ │ ├── ConnectDataverseCmdlet.cs │ │ ├── ConnectDataverseNewCmdlet.cs │ │ └── SendDataverseOperationCmdlet.cs │ ├── Int32Converter.cs │ ├── BatchException.cs │ └── SampleResponses │ │ ├── BatchResponse-Error-DuplicateContentId.json │ │ └── BatchResponse-Error-UnsupportedVerb.json │ ├── Extensions │ ├── IDictionary.cs │ ├── StringExtensions.cs │ ├── ClientApplicationExtensions.cs │ └── AssertExtensions.cs │ ├── Auth │ ├── IAuthenticator.cs │ ├── ClientAppAuthenticator.cs │ ├── DeviceCodeAuthenticator.cs │ ├── AuthenticationService.cs │ ├── IntegratedAuthenticator.cs │ ├── AuthenticationHelper.cs │ ├── AuthenticationParameters.cs │ └── DelegatingAuthenticator.cs │ ├── Globals.cs │ ├── WindowHelper.cs │ ├── PSObjectExtentions.cs │ ├── ScribanExtensionCache.cs │ ├── ScribanExtensions.cs │ ├── Commands │ └── ConvertToCustomTextCmdlet.cs │ ├── PSDataverse.csproj │ ├── AsyncDictionary.cs │ ├── HttpClientFactory.cs │ └── Startup.cs ├── LICENSE ├── .github └── workflows │ ├── build-and-test.yml │ ├── build-and-release.yml │ └── powershell.yml ├── .vscode ├── tasks.json └── launch.json ├── PSDataverse.sln └── README.md /tests/PSDataverse.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /nuget-repository/ 2 | /output/ 3 | bin/ 4 | obj/ 5 | /.vs/ 6 | .DS_Store -------------------------------------------------------------------------------- /media/PSDataverse-Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rezanid/PSDataverse/HEAD/media/PSDataverse-Logo.png -------------------------------------------------------------------------------- /src/Module/PSDataverse.psm1: -------------------------------------------------------------------------------- 1 | $functionsPath = Join-Path $PSScriptRoot "PSFunctions" 2 | foreach($file in Get-ChildItem -Path $functionsPath -Filter "*.ps1") { 3 | . $file 4 | } 5 | -------------------------------------------------------------------------------- /src/PSDataverse/Resources.resj: -------------------------------------------------------------------------------- 1 | { 2 | "Strings": { 3 | "AssertStringNotEmptyInvalidError": "{0} is not set", 4 | "AssertStringNotEmptyInvalidPrefix": "string", 5 | "AssertNumberPositiveInvalidError": "{0} must be greater than zero", 6 | "AssertNumberPositiveInvalidPrefix":"number", 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Execute/IOperationReporter.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Execute; 2 | using System.Management.Automation; 3 | 4 | public interface IOperationReporter 5 | { 6 | void WriteError(ErrorRecord errorRecord); 7 | void WriteInformation(string messageData, string[] tags); 8 | void WriteObject(object obj); 9 | } 10 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Execute/BatchResult.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Execute; 2 | 3 | using System; 4 | using PSDataverse.Dataverse.Model; 5 | 6 | public class BatchResult 7 | { 8 | public TimeSpan Elapsed { get; set; } 9 | public BatchResponse Response { get; set; } 10 | public BatchResult(TimeSpan elapsed, BatchResponse response) 11 | { 12 | Elapsed = elapsed; 13 | Response = response; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Execute/IBatchProcessor.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Execute; 2 | 3 | using System.Collections.Generic; 4 | using PSDataverse.Dataverse.Model; 5 | 6 | public interface IBatchProcessor 7 | { 8 | //Task ProcessAsync(Batch batch); 9 | //IAsyncEnumerable ProcessAsync(Batch batch); 10 | IAsyncEnumerable ProcessAsync(Batch batch); 11 | string ExtractEntityName(Operation operation); 12 | } 13 | -------------------------------------------------------------------------------- /src/PSDataverse/Extensions/IDictionary.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Extensions; 2 | 3 | using System.Collections; 4 | 5 | public static class IDictionaryExtensions 6 | { 7 | public static bool TryGetValue(this IDictionary dictionary, object key, out object value) 8 | { 9 | if (dictionary.Contains(key)) 10 | { 11 | value = dictionary[key]; 12 | return true; 13 | } 14 | value = null; 15 | return false; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/ParseException.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse; 2 | 3 | using System; 4 | using System.Runtime.Serialization; 5 | 6 | [Serializable] 7 | public class ParseException : Exception 8 | { 9 | public ParseException() { } 10 | 11 | public ParseException(string message) : base(message) { } 12 | 13 | public ParseException(string message, Exception innerException) : base(message, innerException) { } 14 | 15 | protected ParseException(SerializationInfo info, StreamingContext context) : base(info, context) { } 16 | } 17 | -------------------------------------------------------------------------------- /src/PSDataverse/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Extensions; 2 | 3 | public static class StringExtensions 4 | { 5 | /// 6 | /// Satisfy NRT checks by ensuring a null string is never propagated 7 | /// 8 | /// 9 | /// Various legacy APIs still return nullable strings (even if, in practice they 10 | /// never will actually be null) so we can use this extension to keep the NRT 11 | /// checks quiet 12 | public static string EmptyWhenNull(this string? str) => str ?? string.Empty; 13 | } 14 | -------------------------------------------------------------------------------- /src/PSDataverse/Extensions/ClientApplicationExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | 3 | using Microsoft.Identity.Client; 4 | 5 | public static class ClientApplicationExtensions 6 | { 7 | public static IConfidentialClientApplication AsConfidentialClient(this IClientApplicationBase app) => app as IConfidentialClientApplication; 8 | public static IPublicClientApplication AsPublicClient(this IClientApplicationBase app) => app as IPublicClientApplication; 9 | public static IByRefreshToken AsRefreshTokenClient(this IClientApplicationBase app) => app as IByRefreshToken; 10 | } 11 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Execute/Processor.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Execute; 2 | 3 | using PSDataverse.Dataverse.Model; 4 | 5 | public abstract class Processor 6 | { 7 | public string ExtractEntityName(Operation operation) 8 | { 9 | var uriSegments = operation.Uri.Split('/'); 10 | var entitySegment = uriSegments[^1] == "$ref" ? uriSegments[^3] : uriSegments[^1]; 11 | var entityNameEnd = entitySegment.IndexOf("(", System.StringComparison.Ordinal); 12 | if (entityNameEnd == -1) 13 | { entityNameEnd = entitySegment.Length; } 14 | return entitySegment[..entityNameEnd]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/ThrottlingExceededException.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse; 2 | 3 | using System; 4 | using Newtonsoft.Json; 5 | 6 | public class ThrottlingExceededException : Exception 7 | { 8 | public WebApiFault Details { get; set; } 9 | 10 | public ThrottlingExceededException() : base() { } 11 | public ThrottlingExceededException(string message) : base(message) { } 12 | public ThrottlingExceededException(string message, Exception inner) : base(message, inner) { } 13 | public ThrottlingExceededException(WebApiFault details) : base(details.Message) => Details = details; 14 | public override string ToString() => JsonConvert.SerializeObject(Details, Formatting.None); 15 | } 16 | -------------------------------------------------------------------------------- /src/PSDataverse/Auth/IAuthenticator.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Microsoft.Identity.Client; 7 | 8 | internal interface IAuthenticator 9 | { 10 | internal IAuthenticator NextAuthenticator { get; set; } 11 | 12 | internal Task AuthenticateAsync( 13 | AuthenticationParameters parameters, Action onMessageForUser = default, CancellationToken cancellationToken = default); 14 | 15 | internal bool CanAuthenticate(AuthenticationParameters parameters); 16 | 17 | internal Task TryAuthenticateAsync( 18 | AuthenticationParameters parameters, Action onMessageForUser = default, CancellationToken cancellationToken = default); 19 | } 20 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Model/OperationError.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Model; 2 | 3 | using System; 4 | using Newtonsoft.Json; 5 | 6 | [Serializable] 7 | public class OperationError 8 | { 9 | [JsonProperty("code", NullValueHandling = NullValueHandling.Ignore)] 10 | public string Code { get; set; } 11 | [JsonProperty("message")] 12 | public string Message { get; set; } 13 | [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] 14 | public string Type { get; set; } 15 | [JsonProperty("stacktrace", NullValueHandling = NullValueHandling.Ignore)] 16 | public string StackTrace { get; set; } 17 | [JsonProperty("innererror", NullValueHandling = NullValueHandling.Ignore)] 18 | public OperationError InnerError { get; set; } 19 | 20 | public override string ToString() => JsonConvert.SerializeObject(this, Formatting.None); 21 | } 22 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Commands/DisconnectDataverseCmdlet.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Commands; 2 | using System; 3 | using System.Management.Automation; 4 | 5 | [Cmdlet(VerbsCommunications.Disconnect, "Dataverse")] 6 | public class DisconnectDataverseCmdlet : PSCmdlet 7 | { 8 | protected override void ProcessRecord() 9 | { 10 | SessionState.PSVariable.Remove(Globals.VariableNameIsOnPremise); 11 | SessionState.PSVariable.Remove(Globals.VariableNameAuthResult); 12 | SessionState.PSVariable.Remove(Globals.VariableNameAccessToken); 13 | SessionState.PSVariable.Remove(Globals.VariableNameAccessTokenExpiresOn); 14 | SessionState.PSVariable.Remove(Globals.VariableNameConnectionString); 15 | var serviceProvider = (IServiceProvider)GetVariableValue(Globals.VariableNameServiceProvider); 16 | WriteInformation("Dataverse disconnected successfully.", ["dataverse"]); 17 | } 18 | } -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Model/WebApiFault.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse; 2 | 3 | using System; 4 | using Newtonsoft.Json; 5 | 6 | public class WebApiFault 7 | { 8 | [JsonIgnore] 9 | public TimeSpan? RetryAfter { get; set; } 10 | 11 | [JsonProperty()] 12 | public string Message { get; set; } 13 | 14 | [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] 15 | public string ExceptionMessage { get; set; } 16 | 17 | [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] 18 | public string ExceptionType { get; set; } 19 | 20 | [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] 21 | public string StackTrace { get; set; } 22 | 23 | [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] 24 | [JsonConverter(typeof(Int32Converter))] 25 | public int? ErrorCode { get; set; } 26 | 27 | public override string ToString() => JsonConvert.SerializeObject(this, Formatting.None); 28 | } 29 | -------------------------------------------------------------------------------- /src/Module/PSFunctions/Export-DataverseOptionSet.ps1: -------------------------------------------------------------------------------- 1 | function Export-DataverseOptionSet { 2 | [CmdletBinding(SupportsShouldProcess = $True)] 3 | param ( 4 | [Parameter(Mandatory = $true, ValueFromPipeline = $true, HelpMessage = "Enter one or more OptionSet names separated by commas.")] 5 | [string[]] 6 | $OptionSet 7 | ) 8 | #TODO: Support switch argument (-NameValueOnly) 9 | #TODO: Support switch argument (-LanguageCode) with default value 1033 10 | process { 11 | foreach ($name in $OptionSet) { 12 | if(!$PSCmdlet.ShouldProcess($name)) { continue } 13 | 14 | Send-DataverseOperation "GlobalOptionSetDefinitions(Name='$OptionSet')" ` 15 | | Select-Object -ExpandProperty Content ` 16 | | ConvertFrom-Json ` 17 | | Select-Object -ExpandProperty Options 18 | | Select-Object -ExpandProperty Label -Property Value 19 | | Select-Object -ExpandProperty LocalizedLabels -Property Value 20 | | Where-Object { $_.LanguageCode -eq 1033 } 21 | | Select-Object Value, Label 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Reza Niroomand 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/PSDataverse.Tests/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | // Use IntelliSense to find out which attributes exist for C# debugging 6 | // Use hover for the description of the existing attributes 7 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 8 | "name": ".NET Core Launch (console)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/bin/Debug/net6.0/PSDataverse.Tests.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}", 16 | // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console 17 | "console": "internalConsole", 18 | "stopAtEntry": false 19 | }, 20 | { 21 | "name": ".NET Core Attach", 22 | "type": "coreclr", 23 | "request": "attach" 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /src/PSDataverse/Auth/ClientAppAuthenticator.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Auth; 2 | 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Microsoft.Identity.Client; 7 | 8 | internal class ClientAppAuthenticator : DelegatingAuthenticator 9 | { 10 | public override async Task AuthenticateAsync(AuthenticationParameters parameters, Action onMessageForUser = default, CancellationToken cancellationToken = default) 11 | { 12 | var app = await GetClientAppAsync(parameters, cancellationToken).ConfigureAwait(false); 13 | 14 | //TODO: Implement logging 15 | //ServiceClientTracing.Information($"[DeviceCodeAuthenticator] Calling AcquireTokenWithDeviceCode - Scopes: '{string.Join(", ", parameters.Scopes)}'"); 16 | 17 | return await app.AsConfidentialClient().AcquireTokenForClient(parameters.Scopes).ExecuteAsync(cancellationToken).ConfigureAwait(false); 18 | } 19 | 20 | public override bool CanAuthenticate(AuthenticationParameters parameters) => 21 | !parameters.UseDeviceFlow && 22 | !string.IsNullOrEmpty(parameters.ClientId) && 23 | (!string.IsNullOrEmpty(parameters.ClientSecret) || !string.IsNullOrEmpty(parameters.CertificateThumbprint)); 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/PSDataverse/Globals.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | public static class Globals 3 | { 4 | public const string DataverseHttpClientName = "Dataverse"; 5 | public const string VariableNameAuthResult = "Dataverse-AuthResult"; 6 | public const string VariableNameAccessToken = "Dataverse-AuthToken"; 7 | public const string VariableNameAccessTokenExpiresOn = "Dataverse-AuthTokenExpiresOn"; 8 | public const string VariableNameOperationProcessor = "Dataverse-OperationProcessor"; 9 | public const string VariableNameBatchProcessor = "Dataverse-BatchProcessor"; 10 | public const string VariableNameServiceProvider = "Dataverse-ServiceProvider"; 11 | public const string VariableNameConnectionString = "Dataverse-ConnectionString"; 12 | public const string VariableNameIsOnPremise = "Dataverse-IsOnPremise"; 13 | public const string PolicyNameHttp = "httpPolicy"; 14 | public const string ErrorIdAuthenticationFailed = "DVERR-901"; 15 | public const string ErrorIdBatchFailure = "DVERR-1001"; 16 | public const string ErrorIdNotConnected = "DVERR-1001"; 17 | public const string ErrorIdConnectionExpired = "DVERR-1002"; 18 | public const string ErrorIdMissingOperation = "DVERR-1003"; 19 | public const string ErrorIdOperationException = "DVERR-1010"; 20 | } 21 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Model/ErrorCodes.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Model; 2 | 3 | using System.Collections.Generic; 4 | 5 | public class ErrorCode 6 | { 7 | public int Code { get; set; } 8 | public string Message { get; set; } 9 | 10 | public static Dictionary Samples => new() 11 | { 12 | [0x80060891] = "A record with the specified key values does not exist in email entity", 13 | [0x80044339] = "Cannot obtain lock on resource:'Email_77f15f8b-5cf8-ec11-bb3d-000d3ade7928', mode:Update - stored procedure sp_getapplock returned error code -3. The lock request was chosen as a deadlock victim. This can occur when there are multiple update requests on the same record. Please retry the request.", 14 | [0x80040278] = "Invalid character in field 'description': '\b', hexadecimal value 0x08, is an invalid character.", 15 | [0x80040216] = "Create failed for the attachment", 16 | [0x80043e09] = "The attachment is either not a valid type or is too large. It cannot be uploaded or downloaded.", 17 | [0x80048105] = "More than one concurrent GrantInheritedAccess requests detected for an Entity adf2ddb2-c97e-4cdc-986e-051d6e26f823 and ObjectTypeCode 380.", 18 | [0x80072560] = "The user is not a member of the organization." 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | DOTNET_VERSION: '8.0.x' 11 | BINARY_MODULE_PATH: '.' 12 | MODULE_NAME: 'PSDataverse' 13 | 14 | jobs: 15 | build: 16 | 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | 23 | - name: Setup .NET ${{ env.DOTNET_VERSION }} 24 | uses: actions/setup-dotnet@v2 25 | with: 26 | dotnet-version: ${{ env.DOTNET_VERSION }} 27 | 28 | - name: Restore dependencies 29 | run: | 30 | pushd '${{ env.BINARY_MODULE_PATH}}' 31 | dotnet restore 32 | popd 33 | 34 | - name: Build 35 | run: | 36 | pushd '${{ env.BINARY_MODULE_PATH}}' 37 | dotnet build --no-restore --configuration Release --output ./output/bin 38 | popd 39 | 40 | - name: Test (.NET) 41 | run: | 42 | pushd '${{ env.BINARY_MODULE_PATH}}' 43 | dotnet test --no-build --verbosity normal --output ./output/bin 44 | popd 45 | 46 | - name: Prepare package 47 | shell: pwsh 48 | run: | 49 | pushd '${{ env.BINARY_MODULE_PATH}}' 50 | Copy-Item "./src/Module/*" ./output 51 | popd 52 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Int32Converter.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse; 2 | 3 | using System; 4 | using System.Globalization; 5 | using Newtonsoft.Json; 6 | using JsonConverter = Newtonsoft.Json.JsonConverter; 7 | 8 | internal class Int32Converter : JsonConverter 9 | { 10 | public override bool CanConvert(Type objectType) => typeof(int).IsAssignableFrom(objectType); 11 | 12 | public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) 13 | { 14 | var existingIsNull = existingValue == null; 15 | if (!(existingIsNull || existingValue is int)) 16 | { 17 | throw new JsonSerializationException("Int32Converter cannot read JSON with the specified existing value. System.Int32 is required."); 18 | } 19 | var value = (string)reader.Value; 20 | return value == null ? null : new System.ComponentModel.Int32Converter().ConvertFromInvariantString(value); 21 | } 22 | 23 | public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) 24 | { 25 | if (value is not int) 26 | { 27 | throw new JsonSerializationException("Converter cannot write specified value to JSON. Int32 is required."); 28 | } 29 | writer.WriteValue("0x" + ((int)value).ToString("x", CultureInfo.InvariantCulture)); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/PSDataverse/WindowHelper.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | 3 | using System; 4 | using System.Runtime.InteropServices; 5 | 6 | public static class WindowHelper 7 | { 8 | public enum GetAncestorEnums 9 | { 10 | GetParent = 1, 11 | GetRoot = 2, 12 | /// 13 | /// Retrieves the owned root window by walking the chain of parent and owner windows returned by GetParent. 14 | /// 15 | GetRootOwner = 3 16 | } 17 | 18 | /// 19 | /// Retrieves the handle to the ancestor of the specified window. 20 | /// 21 | /// A handle to the window whose ancestor is to be retrieved. 22 | /// If this parameter is the desktop window, the function returns NULL. 23 | /// The ancestor to be retrieved. 24 | /// The return value is the handle to the ancestor window. 25 | [DllImport("user32.dll", ExactSpelling = true)] 26 | private static extern IntPtr GetAncestor(IntPtr hwnd, GetAncestorEnums flags); 27 | 28 | [DllImport("kernel32.dll")] 29 | private static extern IntPtr GetConsoleWindow(); 30 | 31 | public static IntPtr GetConsoleOrTerminalWindow() 32 | { 33 | var consoleHandle = GetConsoleWindow(); 34 | var handle = GetAncestor(consoleHandle, GetAncestorEnums.GetRootOwner); 35 | return handle; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/PSDataverse/PSObjectExtentions.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | using System; 3 | using System.Collections; 4 | using System.Management.Automation; 5 | 6 | public static class PSObjectExtentions 7 | { 8 | 9 | internal static string GetPSPropertyInfoValue(this PSPropertyInfo property) 10 | { 11 | if (property == null) 12 | { throw new ArgumentNullException(nameof(property)); } 13 | 14 | try 15 | { 16 | return property.Value?.ToString(); 17 | } 18 | catch (Exception) 19 | { 20 | // If we cannot read some value, treat it as null. 21 | } 22 | 23 | return null; 24 | } 25 | 26 | internal static string TryGetPropertyValue(this PSObject inputObject, string propertyName) 27 | { 28 | if (inputObject.BaseObject is IDictionary dictionary) 29 | { 30 | if (dictionary.Contains(propertyName)) 31 | { 32 | return dictionary[propertyName].ToString(); 33 | } 34 | else if (inputObject.Properties[propertyName] is PSPropertyInfo property) 35 | { 36 | return GetPSPropertyInfoValue(property); 37 | } 38 | } 39 | else if (inputObject.Properties[propertyName] is PSPropertyInfo property) 40 | { 41 | return GetPSPropertyInfoValue(property); 42 | } 43 | return null; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/PSDataverse.Tests/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/PSDataverse.Tests.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile", 15 | "group": { 16 | "kind": "build", 17 | "isDefault": true 18 | } 19 | }, 20 | { 21 | "label": "publish", 22 | "command": "dotnet", 23 | "type": "process", 24 | "args": [ 25 | "publish", 26 | "${workspaceFolder}/PSDataverse.Tests.csproj", 27 | "/property:GenerateFullPaths=true", 28 | "/consoleloggerparameters:NoSummary" 29 | ], 30 | "problemMatcher": "$msCompile" 31 | }, 32 | { 33 | "label": "watch", 34 | "command": "dotnet", 35 | "type": "process", 36 | "args": [ 37 | "watch", 38 | "run", 39 | "--project", 40 | "${workspaceFolder}/PSDataverse.Tests.csproj" 41 | ], 42 | "problemMatcher": "$msCompile" 43 | } 44 | ] 45 | } -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/BatchException.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse; 2 | 3 | using System; 4 | using System.Runtime.Serialization; 5 | using PSDataverse.Dataverse.Model; 6 | 7 | [Serializable] 8 | public class BatchException : Exception 9 | { 10 | public Batch Batch { get; set; } 11 | public Guid CorrelationId { get; set; } 12 | public BatchException() { } 13 | public BatchException(string message) : base(message) { } 14 | public BatchException(string message, Exception inner) : base(message, inner) { } 15 | protected BatchException( 16 | SerializationInfo info, 17 | StreamingContext context) : base(info, context) 18 | { 19 | Batch = Batch.Parse(info.GetString("Batch")); 20 | _ = Guid.TryParse(info.GetString("CorrelationId"), out var correlationId); 21 | CorrelationId = correlationId; 22 | } 23 | //[SecurityPermission(SecurityAction.Demand, SerializationFormatter = true)] 24 | public override void GetObjectData(SerializationInfo info, StreamingContext context) 25 | { 26 | if (info == null) 27 | { throw new ArgumentNullException(nameof(info)); } 28 | // To add batch as a custom type: info.AddValue("Batch", Batch, typeof(Batch)) 29 | info.AddValue("Batch", Batch.ToJsonCompressedBase64(System.IO.Compression.CompressionLevel.Optimal)); 30 | info.AddValue("CorrelationId", CorrelationId.ToString()); 31 | base.GetObjectData(info, context); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "type": "shell", 7 | "command": "Import-Module '${workspaceFolder}/build.psm1'; Start-PSDataverseBuild -Output (Join-Path '${workspaceFolder}' output PSDataverse)", 8 | "group": { 9 | "kind": "build", 10 | "isDefault": true 11 | }, 12 | "problemMatcher": "$msCompile" 13 | }, 14 | { 15 | "label": "build (standard)", 16 | "command": "dotnet", 17 | "type": "process", 18 | "args": [ 19 | "build", 20 | "${workspaceFolder}/src/PSDataverse/PSDataverse.csproj", 21 | "/property:GenerateFullPaths=true", 22 | "/consoleloggerparameters:NoSummary", 23 | "-o", 24 | "${workspaceFolder}/output/PSDataverse" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "publish (standard)", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "publish", 34 | "${workspaceFolder}/src/PSDataverse/PSDataverse.csproj", 35 | "/property:GenerateFullPaths=true", 36 | "/consoleloggerparameters:NoSummary" 37 | ], 38 | "problemMatcher": "$msCompile" 39 | }, 40 | { 41 | "label": "watch (standard)", 42 | "command": "dotnet", 43 | "type": "process", 44 | "args": [ 45 | "watch", 46 | "run", 47 | "--project", 48 | "${workspaceFolder}/src/PSDataverse/PSDataverse.csproj" 49 | ], 50 | "problemMatcher": "$msCompile" 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /src/PSDataverse/ScribanExtensionCache.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using Humanizer; 6 | using PSDataverse.Extensions; 7 | using Scriban.Runtime; 8 | 9 | public static class ScribanExtensionCache 10 | { 11 | public enum KnownAssemblies 12 | { 13 | Humanizr, 14 | } 15 | 16 | private static readonly Dictionary CachedResults = []; 17 | 18 | public static ScriptObject GetHumanizrMethods() => GetOrCreate( 19 | KnownAssemblies.Humanizr, 20 | () => 21 | { 22 | //force a load of the DLL otherwise we won't see the types 23 | "force load".Humanize(); 24 | return AppDomain.CurrentDomain 25 | .GetAssemblies() 26 | .Single(a => a.FullName.EmptyWhenNull().Contains("Humanizer")) 27 | .GetTypes() 28 | .Where(t => t.Name.EndsWith("Extensions", StringComparison.OrdinalIgnoreCase)) 29 | .ToArray(); 30 | }); 31 | 32 | private static ScriptObject GetOrCreate(KnownAssemblies name, Func> typeFetcher) 33 | { 34 | if (CachedResults.TryGetValue(name, out var scriptObject)) 35 | { 36 | return scriptObject; 37 | } 38 | 39 | scriptObject = []; 40 | foreach (var extensionClass in typeFetcher()) 41 | { 42 | scriptObject.Import(extensionClass); 43 | } 44 | 45 | CachedResults[name] = scriptObject; 46 | 47 | return scriptObject; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/PSDataverse/Extensions/AssertExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Extensions; 2 | 3 | using System; 4 | using System.Globalization; 5 | 6 | /// 7 | /// Groups useful extension methods used for validation. 8 | /// 9 | public static class AssertExtensions 10 | { 11 | public static void AssertArgumentNotNull(this object argument, string argumentName) 12 | { 13 | if (argument == null) 14 | { throw new ArgumentNullException(argumentName); } 15 | } 16 | 17 | public static void AssertArgumentNotEmpty(this string argument, string argumentName) 18 | { 19 | if (string.IsNullOrWhiteSpace(argument)) 20 | { 21 | throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, Resources.AssertStringNotEmptyInvalidError, argumentName ?? Resources.AssertStringNotEmptyInvalidPrefix)); 22 | } 23 | } 24 | 25 | public static void AssertPositive(this int argument, string argumentName) 26 | { 27 | if (argument <= 0) 28 | { 29 | throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, Resources.AssertNumberPositiveInvalidError, argumentName ?? Resources.AssertNumberPositiveInvalidPrefix)); 30 | } 31 | } 32 | 33 | public static void AssertPositive(this decimal argument, string argumentName) 34 | { 35 | if (argument <= 0) 36 | { 37 | throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, Resources.AssertNumberPositiveInvalidError, argumentName ?? Resources.AssertNumberPositiveInvalidPrefix)); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Model/Operation.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Model; 2 | 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Text.Json; 6 | using Newtonsoft.Json; 7 | 8 | [Serializable] 9 | public class Operation 10 | { 11 | [NonSerialized] 12 | private string contentId; 13 | 14 | internal virtual bool HasValue => false; 15 | 16 | public virtual string GetValueAsJsonString() => null; 17 | 18 | [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] 19 | public string ContentId 20 | { 21 | get => contentId; 22 | set => contentId = value; 23 | } 24 | [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] 25 | public Dictionary Headers { get; set; } 26 | public string Method { get; set; } 27 | public string Uri { get; set; } 28 | public int RunCount { get; set; } 29 | public override string ToString() => $"ContentID: {ContentId}, Method: {Method}, Url: {Uri}"; 30 | } 31 | 32 | [Serializable] 33 | public class Operation : Operation 34 | { 35 | public T Value { get; set; } 36 | internal override bool HasValue => Value != null; 37 | public override string GetValueAsJsonString() 38 | { 39 | if (!HasValue) 40 | { return null; } 41 | if (Value is string str) 42 | { return str; } 43 | if (Value is Newtonsoft.Json.Linq.JObject jobj) 44 | { return jobj.ToString(Formatting.None); } 45 | return System.Text.Json.JsonSerializer.Serialize(Value); 46 | } 47 | public override string ToString() => $"ContentID: {ContentId}, Method: {Method}, Url: {Uri}, Value: {Value}"; 48 | } 49 | -------------------------------------------------------------------------------- /tests/PSDataverse.Tests/PSDataverse.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | PreserveNewest 32 | 33 | 34 | PreserveNewest 35 | 36 | 37 | PreserveNewest 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/Module/PSFunctions/Get-DataverseTableRowCount.ps1: -------------------------------------------------------------------------------- 1 | function Get-DataverseTableRowCount { 2 | [CmdletBinding()] 3 | param ( 4 | [Parameter(Mandatory=$true)] 5 | [String]$TableName, 6 | [Parameter(Mandatory=$false)] 7 | [string]$Filter = "" 8 | ) 9 | Write-Progress -Activity "Counting rows" 10 | $meta = Send-DataverseOperation '{"Uri":"EntityDefinitions(LogicalName=''be_filing'')?$select=LogicalCollectionName,PrimaryIdAttribute"}' | Select-Object -ExpandProperty Content | ConvertFrom-Json 11 | $uri = $meta | Select-Object -ExpandProperty LogicalCollectionName 12 | $primaryAttr = $meta | Select-Object -ExpandProperty PrimaryIdAttribute 13 | $uri += "?`$count=true&`$select=$($primaryAttr)""" 14 | if ($Filter -ne "") { $uri += "&`$filter=$($Filter)" } 15 | $resp = Send-DataverseOperation "{""Uri"":""$uri""}" | Select-Object -ExpandProperty Content | ConvertFrom-Json 16 | $next = $resp | Select-Object -ExpandProperty "@odata.nextLink" 17 | $count = $resp | Select-Object -ExpandProperty "@odata.count" 18 | while ($next -ne "") { 19 | Write-Progress -Activity "Counting rows" -Status "Rows counted: $count" 20 | $resp = Send-DataverseOperation "{""Uri"":""$next""}" | Select-Object -ExpandProperty Content | ConvertFrom-Json 21 | if (Get-Member "@odata.nextLink" -InputObject $resp) { 22 | $next = $resp | Select-Object -ExpandProperty "@odata.nextLink" 23 | $count += $resp | Select-Object -ExpandProperty "@odata.count" 24 | } else { 25 | $next = "" 26 | $count += ($resp | Select-Object -ExpandProperty value).Count 27 | } 28 | } 29 | Write-Progress -Activity "Counting rows" -Completed 30 | return $count 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Build -> Test -> Publish 2 | 3 | on: 4 | #pull_request: 5 | # branches: [ "main", "release/*" ] 6 | workflow_dispatch: 7 | 8 | env: 9 | DOTNET_VERSION: '8.0.x' 10 | BINARY_MODULE_PATH: '.' 11 | MODULE_NAME: 'PSDataverse' 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | environment: 18 | name: PowerShell Gallery 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v3 23 | 24 | - name: Setup .NET ${{ env.DOTNET_VERSION }} 25 | uses: actions/setup-dotnet@v2 26 | with: 27 | dotnet-version: ${{ env.DOTNET_VERSION }} 28 | 29 | - name: Restore dependencies 30 | run: | 31 | pushd '${{ env.BINARY_MODULE_PATH}}' 32 | dotnet restore 33 | popd 34 | 35 | - name: Build 36 | run: | 37 | pushd '${{ env.BINARY_MODULE_PATH}}' 38 | dotnet build --no-restore --configuration Release --output ./output/${{ env.MODULE_NAME }}/bin 39 | popd 40 | 41 | - name: Test (.NET) 42 | run: | 43 | pushd '${{ env.BINARY_MODULE_PATH}}' 44 | dotnet test --no-build --verbosity normal --output ./output/${{ env.MODULE_NAME }}/bin 45 | popd 46 | 47 | - name: Prepare package 48 | shell: pwsh 49 | run: | 50 | pushd '${{ env.BINARY_MODULE_PATH}}' 51 | Copy-Item "./src/Module/*" ./output/${{ env.MODULE_NAME }} -Recurse 52 | popd 53 | 54 | - name: Publish 55 | shell: pwsh 56 | run: | 57 | pushd '${{ env.BINARY_MODULE_PATH}}' 58 | Publish-Module -Path ./output/${{ env.MODULE_NAME }} -NuGetApiKey "${{ secrets.POWERSHELL_GALLERY_API_KEY }}" 59 | popd 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/PSDataverse/ScribanExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | 3 | using System.Globalization; 4 | using System.Linq; 5 | using System.Text; 6 | 7 | public static class ScribanExtensions 8 | { 9 | /// 10 | /// Removes all diacritics and spaces from a string. 11 | /// 12 | public static string RemoveDiacriticsAndSpace(this string text) 13 | => text.Normalize(NormalizationForm.FormD).EnumerateRunes() 14 | .Where(c => Rune.GetUnicodeCategory(c) is not UnicodeCategory.NonSpacingMark and not UnicodeCategory.NonSpacingMark) 15 | .Aggregate(new StringBuilder(), (sb, c) => sb.Append(c)).ToString().Normalize(NormalizationForm.FormC); 16 | 17 | public static string Tokenize(string input) 18 | { 19 | if (input == null) 20 | { 21 | return null; 22 | } 23 | 24 | var normalizedString = input.Normalize(NormalizationForm.FormD); 25 | var sb = new StringBuilder(normalizedString.Length); 26 | UnicodeCategory? previousCategory = null; 27 | 28 | foreach (var c in normalizedString) 29 | { 30 | var uc = CharUnicodeInfo.GetUnicodeCategory(c); 31 | if (uc != UnicodeCategory.NonSpacingMark) 32 | { 33 | if (char.IsLetterOrDigit(c)) 34 | { 35 | sb.Append(previousCategory is UnicodeCategory.UppercaseLetter or UnicodeCategory.LowercaseLetter or UnicodeCategory.DecimalDigitNumber ? char.ToLowerInvariant(c) : char.ToUpperInvariant(c)); 36 | } 37 | previousCategory = uc; 38 | } 39 | } 40 | if (char.IsDigit(sb[0])) 41 | { 42 | sb.Insert(0, '_'); 43 | } 44 | return sb.ToString().Normalize(NormalizationForm.FormC); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/powershell.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # 6 | # https://github.com/microsoft/action-psscriptanalyzer 7 | # For more information on PSScriptAnalyzer in general, see 8 | # https://github.com/PowerShell/PSScriptAnalyzer 9 | 10 | name: PSScriptAnalyzer 11 | 12 | on: 13 | push: 14 | branches: [ "main" ] 15 | pull_request: 16 | branches: [ "main" ] 17 | schedule: 18 | - cron: '37 16 * * 2' 19 | 20 | permissions: 21 | contents: read 22 | 23 | jobs: 24 | build: 25 | permissions: 26 | contents: read # for actions/checkout to fetch code 27 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 28 | name: PSScriptAnalyzer 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v3 32 | 33 | - name: Run PSScriptAnalyzer 34 | uses: microsoft/psscriptanalyzer-action@2044ae068e37d0161fa2127de04c19633882f061 35 | with: 36 | # Check https://github.com/microsoft/action-psscriptanalyzer for more info about the options. 37 | # The below set up runs PSScriptAnalyzer to your entire repository and runs some basic security rules. 38 | path: .\ 39 | recurse: true 40 | # Include your own basic security rules. Removing this option will run all the rules 41 | includeRule: '"PSAvoidGlobalAliases", "PSAvoidUsingConvertToSecureStringWithPlainText"' 42 | output: results.sarif 43 | 44 | # Upload the SARIF file generated in the previous step 45 | - name: Upload SARIF results file 46 | uses: github/codeql-action/upload-sarif@v2 47 | with: 48 | sarif_file: results.sarif 49 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Commands/DataverseCmdlet.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | 3 | using System; 4 | using System.Management.Automation; 5 | using System.Threading; 6 | 7 | public abstract class DataverseCmdlet : PSCmdlet, IDisposable 8 | { 9 | private CancellationTokenSource cancellationSource; 10 | protected Guid CorrelationId { get; private set; } 11 | protected CancellationToken CancellationToken => cancellationSource.Token; 12 | protected bool Disposed { get; set; } 13 | 14 | protected override void BeginProcessing() 15 | { 16 | cancellationSource ??= new CancellationTokenSource(); 17 | 18 | if (CorrelationId == default) 19 | { CorrelationId = Guid.NewGuid(); } 20 | } 21 | 22 | protected override void EndProcessing() 23 | { 24 | CleanupCancellationSource(); 25 | base.EndProcessing(); 26 | } 27 | 28 | /// 29 | /// Process the stop (Ctrl+C) signal. 30 | /// 31 | protected override void StopProcessing() 32 | { 33 | CleanupCancellationSource(); 34 | base.StopProcessing(); 35 | } 36 | 37 | private void CleanupCancellationSource() 38 | { 39 | if (cancellationSource == null) 40 | { return; } 41 | if (!cancellationSource.IsCancellationRequested) 42 | { 43 | cancellationSource.Cancel(); 44 | } 45 | 46 | cancellationSource.Dispose(); 47 | cancellationSource = null; 48 | } 49 | 50 | #region Dispose Pattern 51 | 52 | public void Dispose() 53 | { 54 | Dispose(disposing: true); 55 | GC.SuppressFinalize(this); 56 | } 57 | 58 | protected virtual void Dispose(bool disposing) 59 | { 60 | if (Disposed) 61 | { return; } 62 | if (disposing) 63 | { 64 | cancellationSource?.Dispose(); 65 | } 66 | Disposed = true; 67 | } 68 | #endregion 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/PSDataverse/Auth/DeviceCodeAuthenticator.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Auth; 2 | 3 | using System; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Microsoft.Identity.Client; 8 | 9 | internal class DeviceCodeAuthenticator : DelegatingAuthenticator 10 | { 11 | public override async Task AuthenticateAsync( 12 | AuthenticationParameters parameters, 13 | Action onMessageForUser = default, 14 | CancellationToken cancellationToken = default) 15 | { 16 | var app = await GetClientAppAsync(parameters, cancellationToken).ConfigureAwait(false); 17 | 18 | // Attempt to get a token silently from the cache 19 | //TODO: Check if accounts returned from GetAccountAsync is always null. 20 | var accounts = await app.GetAccountsAsync().ConfigureAwait(false); 21 | var account = accounts.FirstOrDefault(); 22 | if (parameters.Account is not null) 23 | { 24 | try 25 | { 26 | var silentResult = await app.AcquireTokenSilent(parameters.Scopes, account).ExecuteAsync(cancellationToken).ConfigureAwait(false); 27 | if (silentResult != null) { return silentResult; } 28 | } 29 | catch (MsalUiRequiredException) 30 | { 31 | // Silent acquisition failed, user interaction required 32 | } 33 | } 34 | 35 | return await app.AsPublicClient().AcquireTokenWithDeviceCode(parameters.Scopes, callback => 36 | { 37 | // Provide the user instructions 38 | onMessageForUser(callback.Message); 39 | return Task.CompletedTask; 40 | }).ExecuteAsync(cancellationToken).ConfigureAwait(false); 41 | } 42 | 43 | public override bool CanAuthenticate(AuthenticationParameters parameters) => 44 | parameters.UseDeviceFlow && 45 | !string.IsNullOrEmpty(parameters.ClientId); 46 | } 47 | -------------------------------------------------------------------------------- /PSDataverse.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.32413.511 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PSDataverse", "src\PSDataverse\PSDataverse.csproj", "{5CA8AB93-A251-4E36-BC31-2DF75D5705B0}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{12E9A227-70ED-4A0E-A754-33809331B6DA}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSDataverse.Tests", "tests\PSDataverse.Tests\PSDataverse.Tests.csproj", "{DCECD5CC-0B8C-4241-AD9D-A8EC6B145498}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 18 | {5CA8AB93-A251-4E36-BC31-2DF75D5705B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 19 | {5CA8AB93-A251-4E36-BC31-2DF75D5705B0}.Debug|Any CPU.Build.0 = Debug|Any CPU 20 | {5CA8AB93-A251-4E36-BC31-2DF75D5705B0}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {5CA8AB93-A251-4E36-BC31-2DF75D5705B0}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {DCECD5CC-0B8C-4241-AD9D-A8EC6B145498}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {DCECD5CC-0B8C-4241-AD9D-A8EC6B145498}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {DCECD5CC-0B8C-4241-AD9D-A8EC6B145498}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {DCECD5CC-0B8C-4241-AD9D-A8EC6B145498}.Release|Any CPU.Build.0 = Release|Any CPU 26 | EndGlobalSection 27 | GlobalSection(SolutionProperties) = preSolution 28 | HideSolutionNode = FALSE 29 | EndGlobalSection 30 | GlobalSection(ExtensibilityGlobals) = postSolution 31 | SolutionGuid = {8B36CDF1-9A9C-4548-8884-75455E9F9BF7} 32 | EndGlobalSection 33 | GlobalSection(NestedProjects) = preSolution 34 | {DCECD5CC-0B8C-4241-AD9D-A8EC6B145498} = {12E9A227-70ED-4A0E-A754-33809331B6DA} 35 | EndGlobalSection 36 | EndGlobal 37 | -------------------------------------------------------------------------------- /src/PSDataverse/Auth/AuthenticationService.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Auth; 2 | 3 | using System; 4 | using System.Net.Http; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Microsoft.Identity.Client; 8 | 9 | internal class AuthenticationService( 10 | IAuthenticator authenticator, 11 | IHttpClientFactory httpClientFactory) 12 | { 13 | private IHttpClientFactory HttpClientFactory { get; } = httpClientFactory; 14 | 15 | public IAuthenticator Authenticator { get; } = authenticator; 16 | 17 | public async Task AuthenticateAsync( 18 | AuthenticationParameters authParams, 19 | Action onMessageForUser = default, 20 | CancellationToken cancellationToken = default) 21 | { 22 | authParams = await EnsureTenantAsync(authParams); 23 | var current = Authenticator; 24 | while (current != null && !current.CanAuthenticate(authParams)) 25 | { 26 | current = current.NextAuthenticator; 27 | } 28 | if (current == null) 29 | { 30 | throw new InvalidOperationException("Unable to detect required authentication flow. Please check the input parameters and try again."); 31 | } 32 | return await current?.AuthenticateAsync(authParams, onMessageForUser, cancellationToken); 33 | } 34 | 35 | private async Task EnsureTenantAsync(AuthenticationParameters authParams) 36 | { 37 | if (string.IsNullOrEmpty(authParams.Tenant)) 38 | { 39 | var url = authParams.Resource; 40 | using var httpClient = HttpClientFactory.CreateClient(Globals.DataverseHttpClientName); 41 | var response = await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, url)).ConfigureAwait(false); 42 | var authUrl = response.Headers.Location; 43 | var tenantId = authUrl.AbsolutePath[1..authUrl.AbsolutePath.IndexOf('/', 1)]; 44 | authParams.Tenant = tenantId; 45 | } 46 | return authParams; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Execute/OperationException.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Model; 2 | 3 | using System; 4 | using System.Runtime.Serialization; 5 | 6 | [Serializable] 7 | public class OperationException : Exception 8 | { 9 | public OperationError Error { get; set; } 10 | public string EntityName { get; set; } 11 | public string BatchId { get; set; } 12 | public Guid CorrelationId { get; set; } 13 | public OperationException() { } 14 | public OperationException(string message) : base(message) { } 15 | public OperationException(string message, Exception inner) : base(message, inner) { } 16 | public OperationException(SerializationInfo info, StreamingContext context) : base(info, context) { } 17 | } 18 | 19 | [Serializable] 20 | public class OperationException : OperationException 21 | { 22 | public Operation Operation { get; set; } 23 | public OperationException() { } 24 | public OperationException(string message) : base(message) { } 25 | public OperationException(string message, Exception inner) : base(message, inner) { } 26 | protected OperationException(SerializationInfo info, StreamingContext context) : base(info, context) 27 | { 28 | Operation = (Operation)info.GetValue("Operation", typeof(Operation)); 29 | Error = (OperationError)info.GetValue("Error", typeof(OperationError)); 30 | EntityName = info.GetString("EntityName"); 31 | BatchId = info.GetString("BatchId"); 32 | CorrelationId = (Guid)info.GetValue("CorrelationId", typeof(Guid)); 33 | } 34 | 35 | //[SecurityPermission(SecurityAction.Demand, SerializationFormatter = true)] 36 | public override void GetObjectData(SerializationInfo info, StreamingContext context) 37 | { 38 | if (info == null) 39 | { throw new ArgumentNullException(nameof(info)); } 40 | info.AddValue("Operation", Operation, typeof(Operation)); 41 | info.AddValue("Error", Error, typeof(OperationError)); 42 | info.AddValue("EntityName", EntityName); 43 | info.AddValue("BatchId", BatchId); 44 | info.AddValue("CorrelationId", CorrelationId); 45 | base.GetObjectData(info, context); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/PSDataverse.Tests/AuthenticationParametersTests.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Tests; 2 | 3 | public class AuthenticationParametersTests 4 | { 5 | [Fact] 6 | public void CanParseClientThumbprint() 7 | { 8 | var str = "authority=https://login.microsoftonline.com/tenant-id/oauth2/authorize;clientid=client-id;thumbprint=certificate-thumbprint;resource=https://environment-name.crm4.dynamics.com/"; 9 | var cnnString = AuthenticationParameters.Parse(str); 10 | Assert.Equal(expected: "https://login.microsoftonline.com/tenant-id/oauth2/authorize", cnnString.Authority); 11 | Assert.Equal(expected: "client-id", cnnString.ClientId); 12 | Assert.Equal(expected: "certificate-thumbprint", cnnString.CertificateThumbprint); 13 | Assert.Equal(expected: "https://environment-name.crm4.dynamics.com/", cnnString.Resource); 14 | } 15 | 16 | [Fact] 17 | public void CanParseClientIdAndSecret() 18 | { 19 | var str = "authority=https://login.microsoftonline.com/tenant-id/oauth2/authorize;clientid=client-id;clientsecret=client-secret;resource=https://environment-name.crm4.dynamics.com/"; 20 | var cnnString = AuthenticationParameters.Parse(str); 21 | Assert.Equal(expected: "https://login.microsoftonline.com/tenant-id/oauth2/authorize", cnnString.Authority); 22 | Assert.Equal(expected: "client-id", cnnString.ClientId); 23 | Assert.Equal(expected: "client-secret", cnnString.ClientSecret); 24 | Assert.Equal(expected: "https://environment-name.crm4.dynamics.com/", cnnString.Resource); 25 | } 26 | 27 | [Fact] 28 | public void CanParseDeviceCode() 29 | { 30 | var str = "authority=https://login.microsoftonline.com/tenant-id/oauth2/authorize;clientid=client-id;resource=https://environment-name.crm4.dynamics.com/;device=true"; 31 | var cnnString = AuthenticationParameters.Parse(str); 32 | Assert.Equal(expected: "https://login.microsoftonline.com/tenant-id/oauth2/authorize", cnnString.Authority); 33 | Assert.Equal(expected: "client-id", cnnString.ClientId); 34 | Assert.True(cnnString.UseDeviceFlow); 35 | Assert.Equal(expected: "https://environment-name.crm4.dynamics.com/", cnnString.Resource); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": ".NET Core Attach", 9 | "type": "coreclr", 10 | "request": "attach" 11 | }, 12 | { 13 | "name": ".NET Core Launch (pwsh)", 14 | "type": "coreclr", 15 | "request": "launch", 16 | "preLaunchTask": "build", 17 | "program": "pwsh", 18 | "args": [ 19 | "-NoExit", 20 | "-NoProfile", 21 | "-Command", 22 | "Import-Module ${workspaceFolder}/output/bin/PSDataverse.dll" 23 | ], 24 | "cwd": "${workspaceFolder}", 25 | "console": "integratedTerminal", 26 | "stopAtEntry": true 27 | }, 28 | { 29 | "name": ".NET Launch (powershell)", 30 | "type": "clr", 31 | "request": "launch", 32 | "preLaunchTask": "build", 33 | "program": "powershell", 34 | "args": [ 35 | "-NoExit", 36 | "-NoProfile", 37 | "-Command", 38 | "Import-Module ${workspaceFolder}/output/bin/netstandard2.0/PSDataverse.dll", 39 | ], 40 | "cwd": "${workspaceFolder}", 41 | "stopAtEntry": true, 42 | "console": "integratedTerminal" 43 | }, 44 | { 45 | "name": "PowerShell Launch Current File", 46 | "type": "PowerShell", 47 | "request": "launch", 48 | "script": "${file}", 49 | "args": [], 50 | "cwd": "${file}" 51 | }, 52 | { 53 | "type": "PowerShell", 54 | "request": "launch", 55 | "name": "PowerShell Launch Current File w/Args Prompt", 56 | "script": "${file}", 57 | "args": [ 58 | "${command:SpecifyScriptArgs}" 59 | ], 60 | "cwd": "${file}" 61 | }, 62 | { 63 | "type": "PowerShell", 64 | "request": "attach", 65 | "name": "PowerShell Attach to Host Process", 66 | "processId": "${command:PickPSHostProcess}", 67 | "runspaceId": 1 68 | }, 69 | { 70 | "type": "PowerShell", 71 | "request": "launch", 72 | "name": "PowerShell Interactive Session", 73 | "cwd": "${workspaceRoot}" 74 | }, 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /src/PSDataverse/Commands/ConvertToCustomTextCmdlet.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Commands; 2 | 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Management.Automation; 7 | using Scriban; 8 | using Scriban.Runtime; 9 | 10 | [Cmdlet(VerbsData.ConvertTo, "CustomText")] 11 | [OutputType(typeof(string))] 12 | public class ConvertToCustomTextCmdlet : PSCmdlet 13 | { 14 | [Parameter(Mandatory = true, ValueFromPipeline = true)] 15 | public PSObject InputObject { get; set; } 16 | 17 | [Parameter(Mandatory = true, Position = 0)] 18 | public string Template { get; set; } 19 | 20 | [Parameter(Position = 1)] 21 | public string OutputFile { get; set; } 22 | 23 | protected override void ProcessRecord() 24 | { 25 | var scriptObject = BuildScriptObject(InputObject); 26 | scriptObject.Import(typeof(ScribanExtensions)); 27 | scriptObject.Add(ScribanExtensionCache.KnownAssemblies.Humanizr.ToString().ToLowerInvariant(), ScribanExtensionCache.GetHumanizrMethods()); 28 | 29 | var context = new TemplateContext(); 30 | context.PushGlobal(scriptObject); 31 | 32 | var templateContent = File.ReadAllText(Template); 33 | var template = Scriban.Template.Parse(templateContent); 34 | 35 | var result = template.Render(context); 36 | 37 | if (string.IsNullOrEmpty(OutputFile)) 38 | { 39 | WriteObject(result); 40 | } 41 | else 42 | { 43 | File.WriteAllText(OutputFile, result); 44 | } 45 | } 46 | 47 | private static ScriptObject BuildScriptObject(PSObject input) 48 | { 49 | var scriptObject = new ScriptObject(); 50 | 51 | foreach (var propInfo in input.Properties) 52 | { 53 | if (propInfo.Value is PSObject obj) 54 | { 55 | scriptObject.Add(propInfo.Name, BuildScriptObject(obj)); 56 | } 57 | else if (propInfo.Value is IEnumerable objs) 58 | { 59 | scriptObject.Add(propInfo.Name, objs.Select(o => o is PSObject pso ? BuildScriptObject(pso) : o).ToList()); 60 | } 61 | else 62 | { 63 | scriptObject.Add(propInfo.Name, propInfo.Value); 64 | } 65 | } 66 | 67 | return scriptObject; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/PSDataverse/Auth/IntegratedAuthenticator.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Auth; 2 | 3 | using Microsoft.Identity.Client; 4 | using System; 5 | using System.Linq; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | //TODO: Implement IDisposable. 10 | internal class IntegratedAuthenticator : DelegatingAuthenticator 11 | { 12 | //TODO: The following dictionary is IDisposable. 13 | private AsyncDictionary apps = new(); 14 | 15 | public override async Task AuthenticateAsync( 16 | AuthenticationParameters parameters, 17 | Action onMessageForUser = default, 18 | CancellationToken cancellationToken = default) 19 | { 20 | AuthenticationResult result = null; 21 | var app = await apps.GetOrAddAsync( 22 | parameters, 23 | async (k, ct) => (await GetClientAppAsync(k, ct)).AsPublicClient(), 24 | cancellationToken); 25 | var accounts = await app.GetAccountsAsync().ConfigureAwait(false); 26 | var firstAccount = accounts.FirstOrDefault(); 27 | try 28 | { 29 | result = await app.AcquireTokenSilent(parameters.Scopes, firstAccount) 30 | .ExecuteAsync(cancellationToken) 31 | .ConfigureAwait(false); 32 | } 33 | catch (MsalUiRequiredException) 34 | { 35 | // Nothing in cache for this account + scope. 36 | try 37 | { 38 | //var phwnd = Process.GetCurrentProcess().MainWindowHandle; 39 | var phwnd = WindowHelper.GetConsoleOrTerminalWindow(); 40 | result = await app.AcquireTokenInteractive(parameters.Scopes) 41 | .WithAccount(accounts.FirstOrDefault()) 42 | .WithParentActivityOrWindow(phwnd) 43 | .ExecuteAsync(cancellationToken) 44 | .ConfigureAwait(false); 45 | } 46 | catch (MsalException ex) 47 | { 48 | onMessageForUser?.Invoke(ex.Message); 49 | //TODO: Logging 50 | } 51 | } 52 | return result; 53 | } 54 | public override bool CanAuthenticate(AuthenticationParameters parameters) 55 | => parameters.UseCurrentUser || parameters.IsUncertainAuthFlow(); 56 | } 57 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Model/JsonToPSObjectConverter.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Model; 2 | 3 | using System; 4 | using System.Collections.Concurrent; 5 | using System.Linq; 6 | using System.Management.Automation; 7 | using System.Text.Json; 8 | using System.Threading.Tasks; 9 | 10 | public class JsonToPSObjectConverter 11 | { 12 | public PSObject FromODataJsonString(string jsonString) 13 | { 14 | try 15 | { 16 | using var document = JsonDocument.Parse(jsonString); 17 | var rootElement = document.RootElement; 18 | 19 | return ConvertJsonElementToPSObject(rootElement) as PSObject; 20 | } 21 | catch (JsonException ex) 22 | { 23 | // You can't use WriteError here because it's specific to the Cmdlet. 24 | // Consider either returning a default value, throwing the exception, or using some other form of error handling. 25 | throw new InvalidOperationException("Failed to parse JSON.", ex); 26 | } 27 | } 28 | 29 | private object ConvertJsonElementToPSObject(JsonElement element) => 30 | element.ValueKind switch 31 | { 32 | JsonValueKind.Object => ConvertJsonObjectToPSObject(element), 33 | JsonValueKind.Array => ConvertJsonArrayToPSObject(element), 34 | _ => element.ToString() 35 | }; 36 | 37 | private PSObject ConvertJsonObjectToPSObject(JsonElement element) 38 | { 39 | var psObj = new PSObject(); 40 | 41 | foreach (var prop in element.EnumerateObject()) 42 | { 43 | psObj.Properties.Add(new PSNoteProperty(prop.Name, ConvertJsonElementToPSObject(prop.Value))); 44 | } 45 | 46 | return psObj; 47 | } 48 | 49 | private object ConvertJsonArrayToPSObject(JsonElement element) 50 | { 51 | const int parallelThreshold = 500; 52 | 53 | if (element.GetArrayLength() > parallelThreshold) 54 | { 55 | var results = new ConcurrentBag(); 56 | Parallel.ForEach(element.EnumerateArray(), item => results.Add(ConvertJsonElementToPSObject(item))); 57 | return results.ToList(); 58 | } 59 | else 60 | { 61 | return element.EnumerateArray() 62 | .Select(ConvertJsonElementToPSObject) 63 | .ToList(); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/PSDataverse.Tests/ConvertToCustomTextTests.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Tests; 2 | 3 | using System.IO; 4 | using System.Management.Automation; 5 | using System.Management.Automation.Runspaces; 6 | using FluentAssertions; 7 | using PSDataverse.Commands; 8 | using Xunit; 9 | 10 | public class ConvertToCustomTextTests 11 | { 12 | [Fact] 13 | public void ProcessRecord_NoOutputFile_ProvidesExpectedResult() 14 | { 15 | // Arrange 16 | var iss = InitialSessionState.CreateDefault(); 17 | iss.Commands.Add(new SessionStateCmdletEntry( 18 | "Get-Command", typeof(Microsoft.PowerShell.Commands.GetCommandCommand), "")); 19 | iss.Commands.Add(new SessionStateCmdletEntry( 20 | "Import-Module", typeof(Microsoft.PowerShell.Commands.ImportModuleCommand), "")); 21 | iss.Commands.Add(new SessionStateCmdletEntry( 22 | "ConvertTo-CustomText", typeof(ConvertToCustomTextCmdlet), "")); 23 | iss.Commands.Add(new SessionStateCmdletEntry( 24 | "ConvertFrom-Json", typeof(Microsoft.PowerShell.Commands.ConvertFromJsonCommand), "")); 25 | var rs = RunspaceFactory.CreateRunspace(iss); 26 | rs.Open(); 27 | using var powershell = PowerShell.Create(); 28 | powershell.Runspace = rs; 29 | //var input = new PSObject(); 30 | //input.Properties.Add(new PSNoteProperty("EntityLogicalName", "account")); 31 | //input.Properties.Add(new PSNoteProperty("Attributes", new PSObject[] 32 | //{ 33 | // new(new { LogicalName = "firstname", DisplayName = "First Name", Type = "string" }), 34 | // new(new { LogicalName = "lastname", DisplayName = "Last Name", Type = "string" }) 35 | //})); 36 | powershell 37 | .AddCommand("Get-Content").AddParameter("Path", Path.Combine(Directory.GetCurrentDirectory(), "samples", "account-Definition.json")) 38 | .AddCommand("ConvertFrom-Json") 39 | .AddCommand("ConvertTo-CustomText") 40 | .AddParameter("Template", Path.Combine(Directory.GetCurrentDirectory(), "samples", "DataverseEntity.sbn")); 41 | 42 | // Act 43 | var results = powershell.Invoke(); 44 | rs.Close(); 45 | File.WriteAllText(Path.Combine(Directory.GetCurrentDirectory(), "account.cs"), results[0].ToString()); 46 | 47 | // Assert 48 | results.Should().NotBeNull(); 49 | results.Should().HaveCount(1); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/PSDataverse/PSDataverse.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | latest 6 | 7 | 8 | $(RestoreSources); 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | runtime; build; native; contentfiles; analyzers; buildtransitive 26 | all 27 | 28 | 29 | 30 | 31 | 32 | true 33 | 34 | 35 | 36 | Public 37 | PSDataverse 38 | Resources 39 | 40 | 41 | 45 | 48 | 49 | -------------------------------------------------------------------------------- /src/Module/PSFunctions/Get-DataverseAttributes.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Returns all the attribute metadata of a given entity in Dataverse. 4 | 5 | .DESCRIPTION 6 | Get-DataverseAttributes is a function that returns all attribute metadata of a given 7 | entity. The attributes can be filtered by type. If attributes are not filtered by type 8 | only the common properties will be retrieved from Dataverse. Filtering attributes by 9 | type will cause the function to include more metadata that is specific to a given type. 10 | 11 | .PARAMETER EntityLogicalName 12 | The logical name of the entity in Dataverse. 13 | 14 | .PARAMETER AttributeType 15 | The type of attributes to retrieve from Dataverse. By default, no filtering is applied. 16 | Filtering attributes by type will cause the function to include more metadata that are 17 | specific to the given type. 18 | 19 | .EXAMPLE 20 | Get-DataverseAttributes -EntityLogicalName 'account', 'contact' 21 | 22 | .EXAMPLE 23 | 'account', 'contact' | Get-DataverseAttributes 24 | 25 | .EXAMPLE 26 | Get-DataverseAttributes -EntityLogicalName 'account' -AttributeType Decimal 27 | 28 | .EXAMPLE 29 | Get-DataverseAttributes -EntityLogicalName 'account' -AttributeType Picklist -Expand OptionSet -Filter "IsValidForRead eq true" 30 | 31 | .INPUTS 32 | String 33 | 34 | .OUTPUTS 35 | PSCustomObject 36 | 37 | .NOTES 38 | Author: Reza Niroomand 39 | Website: www.donebycode.com 40 | X: @rezaniroomand 41 | #> 42 | function Get-DataverseAttributes { 43 | 44 | [CmdletBinding(SupportsShouldProcess)] 45 | param( 46 | [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] 47 | [ValidateNotNullOrEmpty()] 48 | [string[]]$EntityLogicalName, 49 | 50 | [ValidateNotNullOrEmpty()] 51 | [string]$AttributeType, 52 | 53 | [ValidateNotNullOrEmpty()] 54 | [string]$Select, 55 | 56 | [ValidateNotNullOrEmpty()] 57 | [string]$Filter, 58 | 59 | [ValidateNotNullOrEmpty()] 60 | [string]$Expand 61 | ) 62 | 63 | process { 64 | foreach($entity in $EntityLogicalName) { 65 | if ($PSBoundParameters.ContainsKey('AttributeType')) { 66 | $query = "EntityDefinitions(LogicalName='$entity')/Attributes/Microsoft.Dynamics.CRM.$($AttributeType)AttributeMetadata?LabelLanguages=1033" 67 | } else { 68 | $query = "EntityDefinitions(LogicalName='$entity')/Attributes?LabelLanguages=1033" 69 | } 70 | if ($PSBoundParameters.ContainsKey('Select')) { $query += "&`$select=$Select" } 71 | if ($PSBoundParameters.ContainsKey('Filter')) { $query += "&`$filter=$Filter" } 72 | if ($PSBoundParameters.ContainsKey('Expand')) { $query += "&`$expand=$Expand" } 73 | if ($PSCmdlet.ShouldProcess($query, "Send-DataverseOperation")) { 74 | Send-DataverseOperation $query -AutoPaginate | Select-Object -ExpandProperty value 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Module/PSFunctions/Clear-DataverseTable.ps1: -------------------------------------------------------------------------------- 1 | function Clear-DataverseTable { 2 | [CmdletBinding(SupportsShouldProcess = $True)] 3 | param ( 4 | [Parameter(Mandatory = $true, ValueFromPipeline = $true, HelpMessage = "Enter one or more table names separated by commas.")] 5 | [string[]] 6 | $TableName, 7 | [switch]$NoWait 8 | ) 9 | 10 | process { 11 | $jobStatusCodes = @{ 12 | 0 = "Waiting For Resources" 13 | 10 = "Waiting" 14 | 20 = "In Progress" 15 | 21 = "Pausing" 16 | 22 = "Canceling" 17 | 30 = "Succeeded" 18 | 31 = "Failed" 19 | 33 = "Canceled" 20 | } 21 | 22 | foreach ($name in $TableName) { 23 | if(!$PSCmdlet.ShouldProcess($name)) { continue } 24 | 25 | $jobId = Send-DataverseOperation @{ 26 | Uri = "BulkDelete" 27 | Method = "POST" 28 | Value = @{ 29 | QuerySet = @(@{EntityName = $name }) 30 | JobName = "Bulk delete all rows from $name table" 31 | SendEmailNotification = $false 32 | ToRecipients = @(@{ 33 | activitypartyid = "00000000-0000-0000-0000-000000000000" 34 | "@odata.type" = "Microsoft.Dynamics.CRM.activityparty" 35 | }) 36 | CCRecipients = @(@{ 37 | activitypartyid = "00000000-0000-0000-0000-000000000000" 38 | "@odata.type" = "Microsoft.Dynamics.CRM.activityparty" 39 | }) 40 | RecurrencePattern = "" 41 | StartDateTime = Get-Date 42 | RunNow = $false 43 | } 44 | } | Select-Object -ExpandProperty Content | ConvertFrom-Json | Select-Object -ExpandProperty JobId 45 | 46 | if ($NoWait) { return "asyncoperations($jobId)" } 47 | 48 | do { 49 | Start-Sleep -Seconds 1 50 | $result = Send-DataverseOperation "asyncoperations($jobId)?`$select=statuscode,statecode" -InformationAction Ignore | Select-Object -ExpandProperty Content | ConvertFrom-Json 51 | Write-Progress -Activity "BulkDelete" -Status $jobStatusCodes[[int]$result.statuscode] 52 | } while ($result.statecode -ne 3) 53 | Write-Progress -Activity "BulkDelete" -Completed 54 | 55 | switch ($result.statuscode) { 56 | 30 { Write-Information "Operation succeeded. For more information check asyncoperations($jobId)" } 57 | 31 { Write-Error "Operation failed. For more information check asyncoperations($jobId)" } 58 | Default { Write-Warning "Operation canceled. For more information check asyncoperations($jobId)" } 59 | } 60 | 61 | Write-Output "asyncoperations($jobId)" 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /src/PSDataverse/AsyncDictionary.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | 3 | using System; 4 | using System.Collections.Concurrent; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using AsyncKeyedLock; 8 | 9 | //TODO: Implement IDictionary. 10 | public class AsyncDictionary : IDisposable 11 | { 12 | private readonly ConcurrentDictionary dictionary = new(); 13 | private readonly AsyncKeyedLocker locks = new(); 14 | private bool disposedValue; 15 | 16 | public async Task GetOrAddAsync(TKey key, Func> valueFactory) 17 | { 18 | if (dictionary.TryGetValue(key, out var existingValue)) 19 | { 20 | return existingValue; 21 | } 22 | 23 | using (await locks.LockAsync(key)) 24 | { 25 | // Check if the value has been added by another thread 26 | if (dictionary.TryGetValue(key, out existingValue)) 27 | { 28 | return existingValue; 29 | } 30 | 31 | // Create and store the value if successful 32 | var value = await valueFactory(key); 33 | dictionary[key] = value; 34 | 35 | return value; 36 | } 37 | } 38 | 39 | public async Task GetOrAddAsync( 40 | TKey key, 41 | Func> valueFactory, 42 | CancellationToken cancellationToken) 43 | { 44 | if (dictionary.TryGetValue(key, out var existingValue)) 45 | { 46 | return existingValue; 47 | } 48 | 49 | using (await locks.LockAsync(key, cancellationToken)) 50 | { 51 | // Check if the value has been added by another thread 52 | if (dictionary.TryGetValue(key, out existingValue)) 53 | { 54 | return existingValue; 55 | } 56 | 57 | // Create and store the value if successful 58 | var value = await valueFactory(key, cancellationToken); 59 | dictionary[key] = value; 60 | 61 | return value; 62 | } 63 | } 64 | 65 | public bool TryRemove(TKey key, out TValue value) 66 | { 67 | using var lockAcquired = locks.LockOrNull(key, 0); 68 | if (lockAcquired is null) 69 | { // another thread is adding to the dictionary, we want to avoid a race condition 70 | value = default; 71 | return false; 72 | } 73 | return dictionary.TryRemove(key, out value); 74 | } 75 | 76 | #region Disposable 77 | protected virtual void Dispose(bool disposing) 78 | { 79 | if (!disposedValue) 80 | { 81 | if (disposing) 82 | { 83 | locks.Dispose(); 84 | } 85 | disposedValue = true; 86 | } 87 | } 88 | 89 | public void Dispose() 90 | { 91 | // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method 92 | Dispose(disposing: true); 93 | GC.SuppressFinalize(this); 94 | } 95 | #endregion 96 | } 97 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Model/BatchResponse.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Model; 2 | 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Globalization; 6 | using System.IO; 7 | 8 | [Serializable] 9 | public class BatchResponse 10 | { 11 | public string Id { get; } 12 | public string BoundaryId { get; } 13 | public bool IsSuccessful { get; set; } 14 | public List Operations { get; set; } 15 | 16 | public BatchResponse() { } 17 | 18 | public BatchResponse(string batchId, string boundaryId) 19 | { 20 | Id = batchId; 21 | BoundaryId = boundaryId; 22 | Operations = new List(); 23 | } 24 | 25 | public static BatchResponse Parse(string response) 26 | { 27 | var reader = new StringReader(response); 28 | var batchResponse = ParseBatchResponseHeader(reader); 29 | var operationResponse = OperationResponse.Parse(reader); 30 | while (operationResponse != null) 31 | { 32 | batchResponse.Operations.Add(operationResponse); 33 | operationResponse = OperationResponse.Parse(reader); 34 | } 35 | batchResponse.IsSuccessful = 36 | batchResponse.Operations.Count > 1 || batchResponse.Operations[0].Error == null; 37 | return batchResponse; 38 | } 39 | 40 | #region Private Methods 41 | 42 | private static BatchResponse ParseBatchResponseHeader(StringReader reader) 43 | { 44 | //--batchresponse_0ece16b0-e21d-4eb1-8805-feb2a61b887e 45 | var buffer = reader.ReadLine(); 46 | if (!buffer.StartsWith("--batchresponse_", StringComparison.OrdinalIgnoreCase)) 47 | { 48 | var length = Math.Min(buffer.Length, 16); 49 | throw new ParseException( 50 | string.Format(CultureInfo.InvariantCulture, "Line 1: Expected \"--batchresponse_\" but found\"{0}\".", buffer[..length])); 51 | } 52 | var batchResponseId = buffer[16..]; 53 | //Content-Type: multipart/mixed; boundary=changesetresponse_66ffbfa0-8e37-4eb1-b843-1b4260b0235e 54 | buffer = reader.ReadLine(); 55 | if (!buffer.StartsWith("Content-Type:", StringComparison.OrdinalIgnoreCase)) 56 | { 57 | throw new ParseException( 58 | string.Format( 59 | CultureInfo.InvariantCulture, 60 | "Line 2: Expected \"Content-Type:\", but found \"{0}\".", buffer[..13])); 61 | } 62 | var segments = buffer[13..].Trim().Split(new string[] { "; ", ";" }, StringSplitOptions.None); 63 | if (!segments[1].StartsWith("boundary=changesetresponse_", StringComparison.OrdinalIgnoreCase)) 64 | { 65 | throw new ParseException( 66 | string.Format( 67 | CultureInfo.InvariantCulture, 68 | "Line 2: Expected \"boundary=changesetresponse_\" as the second part of content type, but found \"{0}\".", 69 | segments[1][..27])); 70 | } 71 | reader.ReadLine(); 72 | return new BatchResponse(batchResponseId, segments[1][27..]); 73 | } 74 | 75 | #endregion 76 | } 77 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Execute/HttpClientExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Execute; 2 | 3 | using System.Collections.Generic; 4 | using System.Net.Http; 5 | using System.Net.Http.Headers; 6 | using System.Text; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using Newtonsoft.Json; 10 | using PSDataverse.Dataverse.Model; 11 | 12 | public static class HttpClientExtensions 13 | { 14 | public static async Task SendAsJsonAsync( 15 | this HttpClient client, HttpMethod method, string requestUri, IEnumerable values) 16 | { 17 | var sb = new StringBuilder(1000); 18 | var jsonSettings = new JsonSerializerSettings() { DefaultValueHandling = DefaultValueHandling.Ignore }; 19 | foreach (var value in values) 20 | { 21 | sb.Append(JsonConvert.SerializeObject(value, jsonSettings)); 22 | } 23 | var request = new HttpRequestMessage(method, requestUri) { Content = new StringContent(sb.ToString()) }; 24 | request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json"); 25 | return await client.SendAsync(request, CancellationToken.None); 26 | } 27 | 28 | public static async Task SendAsync( 29 | this HttpClient client, HttpMethod method, string requestUri, Batch batch, CancellationToken cancellationToken) 30 | { 31 | var request = new HttpRequestMessage(method, requestUri) 32 | { 33 | Content = new StringContent(batch.ToString()) 34 | }; 35 | request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/mixed;boundary=batch_" + batch.Id); 36 | return await client.SendAsync(request, cancellationToken); 37 | } 38 | 39 | public static async Task SendAsync( 40 | this HttpClient client, Operation operation, CancellationToken cancellationToken) 41 | { 42 | var request = new HttpRequestMessage(new HttpMethod(operation.Method), operation.Uri); 43 | if (operation?.HasValue is true) 44 | { 45 | request.Content = new StringContent(operation?.GetValueAsJsonString()); 46 | request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json"); 47 | } 48 | if (operation.Headers != null) 49 | { 50 | if (request.Content is null) 51 | { 52 | foreach (var header in operation.Headers) 53 | { 54 | request.Headers.Add(header.Key, header.Value); 55 | } 56 | } 57 | else 58 | { 59 | foreach (var header in operation.Headers) 60 | { 61 | if (request.Content != null && 62 | header.Key.StartsWith("Content-", System.StringComparison.OrdinalIgnoreCase)) 63 | { 64 | request.Content.Headers.Add(header.Key, header.Value); 65 | } 66 | else 67 | { 68 | request.Headers.Add(header.Key, header.Value); 69 | } 70 | } 71 | } 72 | } 73 | return await client.SendAsync(request, cancellationToken); 74 | } 75 | } -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Model/ChangeSet.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Model; 2 | 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Globalization; 6 | using System.Linq; 7 | using System.Text; 8 | using Newtonsoft.Json; 9 | using Newtonsoft.Json.Linq; 10 | 11 | [Serializable] 12 | public class ChangeSet 13 | { 14 | private List> _Operations; 15 | 16 | public string Id { get; set; } 17 | 18 | public IEnumerable> Operations 19 | { 20 | get => _Operations; 21 | set => _Operations = value?.ToList(); 22 | } 23 | 24 | public ChangeSet() { } 25 | 26 | public void RemoveOperation(string contentId) 27 | { 28 | var operation = _Operations.Find(o => Equals(o.ContentId == contentId, StringComparison.Ordinal)); 29 | if (operation == null) 30 | { 31 | throw new ArgumentOutOfRangeException(nameof(contentId), $"No operation has been found with the given {nameof(contentId)}."); 32 | } 33 | _Operations.Remove(operation); 34 | } 35 | 36 | public void RemoveOperation(Operation operation) => _Operations.Remove(operation); 37 | 38 | public override string ToString() 39 | { 40 | var sb = new StringBuilder(); 41 | var i = 0; 42 | var toJson = Operations is IEnumerable> ? 43 | new Func(ConvertJObjectToJson) : 44 | Operations is IEnumerable> ? 45 | new Func((a) => (string)a) : 46 | new Func(ConvertToJson); 47 | 48 | if (string.IsNullOrEmpty(Id)) 49 | { 50 | Id = Guid.NewGuid().ToString(); 51 | } 52 | 53 | foreach (var operation in Operations) 54 | { 55 | //if (!operation.ContentId.HasValue) { operation.ContentId = ++i; }; 56 | if (string.IsNullOrEmpty(operation.ContentId)) 57 | { operation.ContentId = (++i).ToString(CultureInfo.InvariantCulture); } 58 | sb.Append("--changeset_").AppendLine(Id); 59 | sb.AppendLine("Content-Type:application/http"); 60 | sb.AppendLine("Content-Transfer-Encoding:binary"); 61 | sb.Append("Content-ID:").AppendLine(operation.ContentId.ToString()).AppendLine(); 62 | sb.Append(operation.Method).Append(' ').Append(operation.Uri).Append(' ').AppendLine("HTTP/1.1"); 63 | if (operation.HasValue) 64 | { sb.AppendLine("Content-Type:application/json;type=entry"); } 65 | if (operation.Headers != null) 66 | { 67 | foreach (var header in operation.Headers) 68 | { 69 | sb.AppendLine(header.Key + ":" + header.Value); 70 | } 71 | } 72 | sb.AppendLine(); 73 | if (operation.HasValue) 74 | { sb.AppendLine(toJson(operation.Value)); } 75 | } 76 | 77 | // Terminator 78 | sb.Append("--changeset_").Append(Id).AppendLine("--"); 79 | 80 | return sb.ToString(); 81 | } 82 | 83 | private string ConvertJObjectToJson(object obj) => ((JObject)obj).ToString(Formatting.None); 84 | 85 | private string ConvertToJson(object obj) => System.Text.Json.JsonSerializer.Serialize(obj); 86 | } 87 | -------------------------------------------------------------------------------- /src/PSDataverse/HttpClientFactory.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | using System; 3 | using System.Net.Http; 4 | using System.Net.Http.Headers; 5 | 6 | public class HttpClientFactory(Uri baseUrl, string apiVersion) : IHttpClientFactory, IDisposable 7 | { 8 | private HttpClient httpClient; 9 | private readonly object @lock = new(); 10 | private readonly Uri baseUrl = new( 11 | baseUrl.AbsoluteUri.EndsWith("/", StringComparison.OrdinalIgnoreCase) 12 | ? baseUrl.AbsoluteUri + $"api/data/{apiVersion}/" 13 | : baseUrl.AbsoluteUri + $"/api/data/{apiVersion}/"); 14 | 15 | public HttpClient GetHttpClientInstance() 16 | { 17 | if (httpClient == null) 18 | { 19 | lock (@lock) 20 | { 21 | if (httpClient == null) 22 | { 23 | httpClient = new HttpClient(CreateHttpClientHandler()); 24 | SetHttpClientDefaults(httpClient); 25 | } 26 | } 27 | } 28 | return httpClient; 29 | } 30 | 31 | #region Dispose Pattern 32 | /// 33 | /// IDisposable implementation, dispose of any disposable resources created by the cmdlet. 34 | /// 35 | public void Dispose() 36 | { 37 | Dispose(disposing: true); 38 | GC.SuppressFinalize(this); 39 | } 40 | 41 | /// 42 | /// Implementation of IDisposable for both manual Dispose() and finalizer-called disposal of resources. 43 | /// 44 | /// 45 | /// Specified as true when Dispose() was called, false if this is called from the finalizer. 46 | /// 47 | protected virtual void Dispose(bool disposing) 48 | { 49 | if (disposing) 50 | { 51 | ((IDisposable)httpClient).Dispose(); 52 | } 53 | } 54 | #endregion 55 | 56 | /// 57 | /// Sets the default values for HttpClient. 58 | /// 59 | /// 60 | /// All uses of HttpClient should never modify the following properties 61 | /// after creation: 62 | /// 63 | /// BaseAddress 64 | /// Timeout 65 | /// MaxResponseContentBufferSize 66 | /// 67 | /// 68 | protected virtual void SetHttpClientDefaults(HttpClient client) 69 | { 70 | client.BaseAddress = baseUrl; 71 | client.Timeout = GetRequestTimeout(); 72 | client.DefaultRequestHeaders.Add("OData-MaxVersion", "4.0"); 73 | client.DefaultRequestHeaders.Add("OData-Version", "4.0"); 74 | client.DefaultRequestHeaders.Accept.Add( 75 | new MediaTypeWithQualityHeaderValue("application/json")); 76 | client.DefaultRequestHeaders.Accept.Add( 77 | new MediaTypeWithQualityHeaderValue("application/xml")); 78 | } 79 | 80 | private static TimeSpan GetRequestTimeout() 81 | { 82 | var requestTimeout = Environment.GetEnvironmentVariable("Request_Timeout", EnvironmentVariableTarget.Process); 83 | if (!string.IsNullOrEmpty(requestTimeout) && TimeSpan.TryParse(requestTimeout, out var timeout)) 84 | { 85 | return timeout; 86 | } 87 | return TimeSpan.FromMinutes(10); 88 | } 89 | 90 | public virtual HttpClientHandler CreateHttpClientHandler() => new() 91 | { 92 | UseCookies = false, 93 | UseDefaultCredentials = true 94 | }; 95 | 96 | public HttpClient CreateClient(string name) => GetHttpClientInstance(); 97 | } 98 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Execute/OperationHandler.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Execute; 2 | using System; 3 | using System.Management.Automation; 4 | using PSDataverse.Dataverse.Model; 5 | 6 | public class OperationHandler 7 | { 8 | private readonly IOperationReporter reporter; 9 | private readonly OperationProcessor processor; 10 | private readonly JsonToPSObjectConverter jsonConverter = new(); 11 | 12 | public OperationHandler(OperationProcessor operationProcessor, IOperationReporter operationReporter) 13 | => (processor, reporter) = (operationProcessor, operationReporter); 14 | 15 | public void ReportMissingOperationError() 16 | { 17 | var errMessage = "No operation has been given. Please provide an operation using either of -InputOperation or -InputObject arguments."; 18 | reporter.WriteError(new ErrorRecord(new InvalidOperationException(errMessage), Globals.ErrorIdMissingOperation, ErrorCategory.ConnectionError, null)); 19 | } 20 | 21 | public static void AssignDefaultHttpMethod(Operation op) 22 | { 23 | if (!op.HasValue && op.Method is null) 24 | { 25 | op.Method = "GET"; 26 | } 27 | else 28 | { 29 | op.Method ??= "POST"; 30 | } 31 | } 32 | 33 | public void ExecuteSingleOperation(Operation op, string accessToken, bool autoPagination) 34 | { 35 | var noError = true; 36 | processor.AuthenticationToken = accessToken; 37 | try 38 | { 39 | var response = processor.ExecuteAsync(op).Result; 40 | var opResponse = OperationResponse.From(response); 41 | 42 | HandleResponsePagination(op, opResponse, autoPagination); 43 | } 44 | catch (OperationException ex) 45 | { 46 | reporter.WriteError(new ErrorRecord(ex, Globals.ErrorIdOperationException, ErrorCategory.WriteError, null)); 47 | noError = false; 48 | } 49 | catch (AggregateException ex) when (ex.InnerException is OperationException) 50 | { 51 | reporter.WriteError(new ErrorRecord(ex.InnerException, Globals.ErrorIdOperationException, ErrorCategory.WriteError, null)); 52 | noError = false; 53 | } 54 | if (noError) { reporter.WriteInformation("Dataverse operation successful.", ["dataverse"]); } 55 | } 56 | 57 | private void HandleResponsePagination(Operation op, OperationResponse opResponse, bool autoPagination) 58 | { 59 | if (string.IsNullOrEmpty(opResponse.ContentId)) 60 | { 61 | opResponse.ContentId = op.ContentId; 62 | } 63 | if (autoPagination) 64 | { 65 | ProcessPaginatedResults(op, opResponse); 66 | } 67 | else 68 | { 69 | reporter.WriteObject(opResponse); 70 | } 71 | } 72 | 73 | private void ProcessPaginatedResults(Operation op, OperationResponse opResponse) 74 | { 75 | do 76 | { 77 | var result = jsonConverter.FromODataJsonString(opResponse.Content); 78 | reporter.WriteObject(result); 79 | var nextPage = result.Properties["@odata.nextLink"]?.Value as string; 80 | 81 | if (!string.IsNullOrEmpty(nextPage)) 82 | { 83 | op.Uri = nextPage; 84 | var response = processor.ExecuteAsync(op).Result; 85 | opResponse = OperationResponse.From(response); 86 | } 87 | else 88 | { 89 | break; 90 | } 91 | } while (true); 92 | } 93 | } 94 | 95 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/SampleResponses/BatchResponse-Error-DuplicateContentId.json: -------------------------------------------------------------------------------- 1 | { 2 | "Message": "The content ID '1' was found more than once in the same change set or same batch request. Content IDs have to be unique across all operations of a change set for OData V4.0 and have to be unique across all operations in the whole batch request for OData V4.01.", 3 | "ExceptionMessage": "The content ID '1' was found more than once in the same change set or same batch request. Content IDs have to be unique across all operations of a change set for OData V4.0 and have to be unique across all operations in the whole batch request for OData V4.01.", 4 | "ExceptionType": "Microsoft.OData.ODataException", 5 | "StackTrace": " at Microsoft.OData.ODataBatchReader.ReadImplementation()\r\n at Microsoft.OData.ODataBatchReader.InterceptException[T](Func`1 action)\r\n at System.Web.OData.Batch.ODataBatchReaderExtensions.d__0.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at Microsoft.Crm.Extensibility.OData.CrmODataBatchHandler.d__16.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)\r\n at Microsoft.Crm.Extensibility.OData.CrmODataBatchHandler.<>c__DisplayClass9_0.<b__0>d.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)\r\n at Microsoft.PowerApps.CoreFramework.ActivityLoggerExtensions.d__11`1.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)\r\n at Microsoft.Xrm.Telemetry.XrmTelemetryExtensions.d__15`1.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)\r\n at Microsoft.Crm.Extensibility.OData.CrmODataUtilities.d__15`2.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)\r\n at Microsoft.Crm.Extensibility.OData.CrmODataBatchHandler.d__9.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)\r\n at System.Web.OData.Batch.DefaultODataBatchHandler.d__0.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)\r\n at System.Web.Http.Batch.HttpBatchHandler.d__0.MoveNext()", 6 | "ErrorCode": "0x0" 7 | } -------------------------------------------------------------------------------- /src/PSDataverse/Auth/AuthenticationHelper.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Auth; 2 | 3 | using System; 4 | using System.Linq; 5 | using System.Security.Cryptography.X509Certificates; 6 | using Extensions; 7 | using Microsoft.Identity.Client; 8 | 9 | internal class AuthenticationHelper 10 | { 11 | public static AuthenticationResult Authenticate(AuthenticationParameters connectionString) 12 | { 13 | if (connectionString == null) 14 | { 15 | throw new ArgumentNullException(nameof(connectionString)); 16 | } 17 | if (!string.IsNullOrEmpty(connectionString.CertificateThumbprint)) 18 | { 19 | var certificate = FindCertificate(connectionString.CertificateThumbprint, StoreName.My) ?? throw new InvalidOperationException($"No certificate found with thumbprint '{connectionString.CertificateThumbprint}'."); 20 | return Authenticate(connectionString.Authority, connectionString.ClientId, connectionString.Resource, certificate); 21 | } 22 | return Authenticate(connectionString.Authority, connectionString.ClientId, connectionString.Resource, connectionString.ClientSecret); 23 | } 24 | 25 | public static AuthenticationResult Authenticate(string authority, string clientId, string resource, X509Certificate2 clientCert) 26 | { 27 | var confidentialClient = ConfidentialClientApplicationBuilder.Create(clientId) 28 | .WithCertificate(clientCert) 29 | .WithAuthority(authority) 30 | .Build(); 31 | return confidentialClient.AcquireTokenForClient(new string[] { $"{resource}/.default" }).ExecuteAsync().ConfigureAwait(false).GetAwaiter().GetResult(); 32 | } 33 | 34 | public static AuthenticationResult Authenticate(string authority, string clientId, string resource, string clientSecret) 35 | { 36 | var confidentialClient = ConfidentialClientApplicationBuilder.Create(clientId) 37 | .WithClientSecret(clientSecret) 38 | .WithAuthority(authority) 39 | .Build(); 40 | return confidentialClient.AcquireTokenForClient(new string[] { $"{resource}/.default" }).ExecuteAsync().ConfigureAwait(false).GetAwaiter().GetResult(); 41 | } 42 | 43 | public static X509Certificate2 FindCertificate( 44 | string thumbprint, 45 | StoreName storeName) 46 | { 47 | if (thumbprint == null) 48 | { return null; } 49 | 50 | var source = new StoreLocation[2] { StoreLocation.CurrentUser, StoreLocation.LocalMachine }; 51 | X509Certificate2 certificate = null; 52 | if (source.Any(storeLocation => TryFindCertificatesInStore(thumbprint, storeLocation, storeName, out certificate))) 53 | { 54 | return certificate; 55 | } 56 | return null; 57 | } 58 | 59 | private static bool TryFindCertificatesInStore(string thumbprint, StoreLocation location, out X509Certificate2 certificate) 60 | => TryFindCertificatesInStore(thumbprint, location, StoreName.My, out certificate); 61 | 62 | private static bool TryFindCertificatesInStore(string thumbprint, StoreLocation location, StoreName storeName, out X509Certificate2 certificate) 63 | { 64 | X509Store store = null; 65 | X509Certificate2Collection col; 66 | 67 | thumbprint.AssertArgumentNotNull(nameof(thumbprint)); 68 | 69 | try 70 | { 71 | store = new X509Store(storeName, location); 72 | store.Open(OpenFlags.ReadOnly); 73 | 74 | col = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); 75 | 76 | certificate = col.Count == 0 ? null : col[0]; 77 | 78 | return col.Count > 0; 79 | } 80 | finally 81 | { 82 | store?.Close(); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/SampleResponses/BatchResponse-Error-UnsupportedVerb.json: -------------------------------------------------------------------------------- 1 | { 2 | "Message": "An invalid HTTP method 'GET' was detected for a request in a change set. Requests in change sets only support the HTTP methods 'POST', 'PUT', 'DELETE', and 'PATCH'.", 3 | "ExceptionMessage": "An invalid HTTP method 'GET' was detected for a request in a change set. Requests in change sets only support the HTTP methods 'POST', 'PUT', 'DELETE', and 'PATCH'.", 4 | "ExceptionType": "Microsoft.OData.ODataException", 5 | "StackTrace": " at Microsoft.OData.MultipartMixed.ODataMultipartMixedBatchReader.ParseRequestLine(String requestLine, String& httpMethod, Uri& requestUri)\r\n at Microsoft.OData.MultipartMixed.ODataMultipartMixedBatchReader.CreateOperationRequestMessageImplementation()\r\n at Microsoft.OData.ODataBatchReader.InterceptException[T](Func`1 action)\r\n at Microsoft.OData.ODataBatchReader.CreateOperationRequestMessage()\r\n at System.Web.OData.Batch.ODataBatchReaderExtensions.d__9.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Web.OData.Batch.ODataBatchReaderExtensions.d__0.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at Microsoft.Crm.Extensibility.OData.CrmODataBatchHandler.d__16.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)\r\n at Microsoft.Crm.Extensibility.OData.CrmODataBatchHandler.<>c__DisplayClass9_0.<b__0>d.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)\r\n at Microsoft.PowerApps.CoreFramework.ActivityLoggerExtensions.d__11`1.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)\r\n at Microsoft.Xrm.Telemetry.XrmTelemetryExtensions.d__15`1.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)\r\n at Microsoft.Crm.Extensibility.OData.CrmODataUtilities.d__15`2.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)\r\n at Microsoft.Crm.Extensibility.OData.CrmODataBatchHandler.d__9.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)\r\n at System.Web.OData.Batch.DefaultODataBatchHandler.d__0.MoveNext()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()\r\n at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)\r\n at System.Web.Http.Batch.HttpBatchHandler.d__0.MoveNext()", 6 | "ErrorCode": "0x0" 7 | } -------------------------------------------------------------------------------- /src/PSDataverse/Startup.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | 3 | using System; 4 | using System.Diagnostics; 5 | using System.Linq; 6 | using System.Net; 7 | using System.Net.Http; 8 | using System.Threading.Tasks; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using Microsoft.Extensions.Logging; 11 | using Microsoft.Extensions.Logging.Abstractions; 12 | using Polly; 13 | using Polly.Registry; 14 | using Polly.Timeout; 15 | using PSDataverse.Auth; 16 | using PSDataverse.Dataverse.Execute; 17 | 18 | internal sealed class Startup(Uri baseUrl, string apiVersion = "v9.2") 19 | { 20 | public IServiceCollection ConfigureServices(IServiceCollection services) 21 | { 22 | _ = services 23 | .AddSingleton(NullLogger.Instance) 24 | .AddSingleton>((s) => SetupRetryPolicies()) 25 | .AddSingleton() 26 | .AddSingleton() 27 | .AddSingleton((provider) => new ClientAppAuthenticator 28 | { 29 | NextAuthenticator = new DeviceCodeAuthenticator 30 | { 31 | NextAuthenticator = new IntegratedAuthenticator() 32 | } 33 | }) 34 | .AddSingleton() 35 | .AddHttpClient(Globals.DataverseHttpClientName, (provider, client) => 36 | { 37 | client.BaseAddress = new( 38 | baseUrl.AbsoluteUri.EndsWith("/", StringComparison.OrdinalIgnoreCase) 39 | ? baseUrl.AbsoluteUri + $"api/data/{apiVersion}/" 40 | : baseUrl.AbsoluteUri + $"/api/data/{apiVersion}/" 41 | ); 42 | client.Timeout = GetDefaultRequestTimeout(); 43 | client.DefaultRequestHeaders.Add("OData-MaxVersion", "4.0"); 44 | client.DefaultRequestHeaders.Add("OData-Version", "4.0"); 45 | client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); 46 | }) 47 | .ConfigureHttpMessageHandlerBuilder(builder => builder.PrimaryHandler = new HttpClientHandler 48 | { 49 | AllowAutoRedirect = false, 50 | UseCookies = false, 51 | UseDefaultCredentials = true, 52 | AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate 53 | }); 54 | return services; 55 | } 56 | 57 | public PolicyRegistry SetupRetryPolicies() 58 | { 59 | HttpStatusCode[] httpStatusCodesWorthRetrying = [ 60 | HttpStatusCode.RequestTimeout, // 408 61 | HttpStatusCode.InternalServerError, // 500 62 | HttpStatusCode.BadGateway, // 502 63 | HttpStatusCode.ServiceUnavailable, // 503 64 | HttpStatusCode.GatewayTimeout, // 504 65 | (HttpStatusCode)429 // Too Many Requests 66 | ]; 67 | 68 | var registry = new PolicyRegistry(); 69 | 70 | var httpPolicy = Policy 71 | .HandleResult(r => httpStatusCodesWorthRetrying.Contains(r.StatusCode)) 72 | .Or() 73 | .WaitAndRetryAsync(5, WaitTimeProvider, OnRetryAsync); 74 | 75 | registry.Add(Globals.PolicyNameHttp, httpPolicy); 76 | return registry; 77 | } 78 | 79 | private TimeSpan WaitTimeProvider(int retryAttempt, DelegateResult response, Context context) 80 | { 81 | var retryAfter = response.Result.Headers.RetryAfter; 82 | if (retryAfter != null) 83 | { 84 | return retryAfter.Delta.Value; 85 | } 86 | return TimeSpan.FromSeconds(3 * Math.Pow(2, retryAttempt)); 87 | } 88 | 89 | private Task OnRetryAsync(DelegateResult response, TimeSpan wait, int retryAttempt, Context context) 90 | { 91 | Debug.WriteLine($"Retry delegate invoked. Attempt {retryAttempt}"); 92 | return Task.CompletedTask; 93 | } 94 | 95 | private static TimeSpan GetDefaultRequestTimeout() 96 | { 97 | var requestTimeout = Environment.GetEnvironmentVariable("Request_Timeout", EnvironmentVariableTarget.Process); 98 | if (!string.IsNullOrEmpty(requestTimeout) && TimeSpan.TryParse(requestTimeout, out var timeout)) 99 | { 100 | return timeout; 101 | } 102 | return TimeSpan.FromMinutes(10); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Module/PSDataverse.psd1: -------------------------------------------------------------------------------- 1 | # 2 | # Module manifest for module 'PSDataverse;' 3 | # 4 | # Generated by: Reza Niroomand 5 | # 6 | 7 | @{ 8 | 9 | # Script module or binary module file associated with this manifest. 10 | RootModule = 'PSDataverse.psm1' 11 | 12 | # Version number of this module. 13 | ModuleVersion = '0.0.16' 14 | 15 | # Supported PSEditions 16 | CompatiblePSEditions = @("Core") 17 | 18 | # ID used to uniquely identify this module 19 | GUID = '081185a0-92be-4624-85e8-4903acb07e03' 20 | 21 | # Author of this module 22 | Author = 'Reza Niroomand' 23 | 24 | # Company or vendor of this moduled 25 | CompanyName = 'Novovio' 26 | 27 | # Copyright statement for this module 28 | Copyright = 'Copyright (c) Novovio.' 29 | 30 | # Description of the functionality provided by this module 31 | Description = 'Bring Dataverse''s Web API to PowerShell.' 32 | 33 | # Minimum version of the PowerShell engine required by this module 34 | PowerShellVersion = '5.1' 35 | 36 | # Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 37 | DotNetFrameworkVersion = '6.0' 38 | 39 | # Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 40 | ClrVersion = '6.0' 41 | 42 | # Modules that must be imported into the global environment prior to importing this module 43 | # RequiredModules = @() 44 | 45 | # Assemblies that must be loaded prior to importing this module 46 | # RequiredAssemblies = @() 47 | 48 | # Script files (.ps1) that are run in the caller's environment prior to importing this module. 49 | # ScriptsToProcess = @() 50 | 51 | # Type files (.ps1xml) to be loaded when importing this module 52 | # TypesToProcess = @() 53 | 54 | # Format files (.ps1xml) to be loaded when importing this module 55 | # FormatsToProcess = @() 56 | 57 | # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess 58 | NestedModules = @('./bin/PSDataverse.dll') 59 | 60 | # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. 61 | FunctionsToExport = @('Clear-DataverseTable') 62 | 63 | # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. 64 | CmdletsToExport = @('Connect-Dataverse', 'Send-DataverseOperation') 65 | 66 | # Variables to export from this module 67 | VariablesToExport = @() 68 | 69 | # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. 70 | AliasesToExport = @() 71 | 72 | # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. 73 | PrivateData = @{ 74 | 75 | PSData = @{ 76 | 77 | # Tags applied to this module. These help with module discovery in online galleries. 78 | Tags = @('PSEdition_Core', 'PSEdition_Desktop', 'Windows', 'Linux', 'macOS', 'Dataverse') 79 | 80 | # A URL to the license for this module. 81 | LicenseUri = 'https://github.com/rezanid/PSDataverse/blob/main/LICENSE' 82 | 83 | # A URL to the main website for this project. 84 | ProjectUri = 'https://github.com/rezanid/PSDataverse' 85 | 86 | # A URL to an icon representing this module. 87 | IconUri = 'https://github.com/rezanid/PSDataverse/raw/268e36c93ddfbcb6bcc2255beed9b03499210dfb/media/PSDataverse-Logo.png' 88 | 89 | # ReleaseNotes of this module 90 | # ReleaseNotes = '' 91 | 92 | # Prerelease string of this module 93 | # Prerelease = '' 94 | 95 | # Flag to indicate whether the module requires explicit user acceptance for install/update/save 96 | RequireLicenseAcceptance = $false 97 | 98 | # External dependent modules of this module 99 | # ExternalModuleDependencies = @() 100 | 101 | IsPrerelease = 'True' 102 | } # End of PSData hashtable 103 | 104 | } # End of PrivateData hashtable 105 | 106 | # HelpInfo URI of this module 107 | # HelpInfoURI = '' 108 | 109 | # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. 110 | # DefaultCommandPrefix = '' 111 | 112 | } 113 | 114 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Commands/ConnectDataverseCmdlet.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | 3 | using System; 4 | using System.Management.Automation; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Identity.Client; 7 | using PSDataverse.Auth; 8 | 9 | [Cmdlet(VerbsCommunications.Connect, "Dataverse", DefaultParameterSetName = "AuthResult")] 10 | public class ConnectDataverseCmdlet : DataverseCmdlet 11 | { 12 | [Parameter(Position = 0, Mandatory = true, ParameterSetName = "Url")] 13 | public string Url { get; set; } 14 | 15 | [Parameter(Position = 0, Mandatory = true, ParameterSetName = "ConnectionString")] 16 | public string ConnectionString { get; set; } 17 | 18 | [Parameter(Mandatory = false, ParameterSetName = "Url")] 19 | public SwitchParameter OnPremise { get; set; } 20 | 21 | [Parameter(DontShow = true, ParameterSetName = "ConnectionString")] 22 | [Parameter(DontShow = true, ParameterSetName = "Url")] 23 | public int Retry { get; set; } 24 | 25 | private static readonly object Lock = new(); 26 | 27 | protected override void ProcessRecord() 28 | { 29 | var serviceProvider = (IServiceProvider)GetVariableValue(Globals.VariableNameServiceProvider); 30 | var authParams = string.IsNullOrWhiteSpace(ConnectionString) ? 31 | new AuthenticationParameters 32 | { 33 | Resource = Url 34 | } : 35 | AuthenticationParameters.Parse(ConnectionString); 36 | 37 | var endpointUrl = 38 | string.IsNullOrWhiteSpace(Url) ? 39 | new Uri(authParams.Resource, UriKind.Absolute) : 40 | new Uri(Url, UriKind.Absolute); 41 | 42 | serviceProvider ??= InitializeServiceProvider(endpointUrl); 43 | 44 | if (OnPremise) 45 | { 46 | SessionState.PSVariable.Set(new PSVariable(Globals.VariableNameIsOnPremise, true, ScopedItemOptions.AllScope)); 47 | SessionState.PSVariable.Set(new PSVariable(Globals.VariableNameAccessToken, string.Empty, ScopedItemOptions.AllScope)); 48 | WriteInformation("Dynamics 365 (On-Prem) authenticated successfully.", ["dataverse"]); 49 | return; 50 | } 51 | SessionState.PSVariable.Set(new PSVariable(Globals.VariableNameIsOnPremise, false, ScopedItemOptions.AllScope)); 52 | 53 | // if previously authented, extract the account. It will be required for silent authentication. 54 | if (SessionState.PSVariable.GetValue(Globals.VariableNameAuthResult) is AuthenticationResult previouAuthResult) 55 | { 56 | authParams.Account = previouAuthResult.Account; 57 | } 58 | 59 | var authResult = HandleAuthentication(serviceProvider, authParams); 60 | if (authResult == null) 61 | { 62 | return; 63 | } 64 | 65 | SessionState.PSVariable.Set(new PSVariable(Globals.VariableNameAuthResult, authResult, ScopedItemOptions.AllScope)); 66 | SessionState.PSVariable.Set(new PSVariable(Globals.VariableNameAccessToken, authResult.AccessToken, ScopedItemOptions.AllScope)); 67 | SessionState.PSVariable.Set(new PSVariable(Globals.VariableNameAccessTokenExpiresOn, authResult.ExpiresOn, ScopedItemOptions.AllScope)); 68 | SessionState.PSVariable.Set(new PSVariable(Globals.VariableNameConnectionString, authParams, ScopedItemOptions.AllScope)); 69 | 70 | WriteDebug("AccessToken: " + authResult.AccessToken); 71 | WriteInformation("Dataverse authenticated successfully.", ["dataverse"]); 72 | } 73 | 74 | private AuthenticationResult HandleAuthentication( 75 | IServiceProvider serviceProvider, 76 | AuthenticationParameters parameters) 77 | { 78 | var service = serviceProvider.GetService(); 79 | try 80 | { 81 | return service?.AuthenticateAsync(parameters, OnMessageForUser, CancellationToken).ConfigureAwait(false).GetAwaiter().GetResult(); 82 | } 83 | catch (OperationCanceledException) 84 | { 85 | WriteError( 86 | new ErrorRecord( 87 | new InvalidOperationException("Dataverse authentication cancelled."), Globals.ErrorIdAuthenticationFailed, ErrorCategory.AuthenticationError, this)); 88 | return null; 89 | } 90 | catch (Exception ex) 91 | { 92 | WriteError( 93 | new ErrorRecord( 94 | new InvalidOperationException("Authentication failed. " + ex.ToString(), ex), Globals.ErrorIdAuthenticationFailed, ErrorCategory.AuthenticationError, this)); 95 | return null; 96 | } 97 | } 98 | 99 | private void OnMessageForUser(string message) => WriteInformation(message, ["dataverse"]); 100 | 101 | private IServiceProvider InitializeServiceProvider(Uri baseUrl) 102 | { 103 | lock (Lock) 104 | { 105 | var serviceProvider = (IServiceProvider)GetVariableValue(Globals.VariableNameServiceProvider); 106 | if (serviceProvider == null) 107 | { 108 | var startup = new Startup(baseUrl, OnPremise ? "v9.1" : "v9.2"); 109 | serviceProvider = startup.ConfigureServices(new ServiceCollection()).BuildServiceProvider(); 110 | SessionState.PSVariable.Set( 111 | new PSVariable(Globals.VariableNameServiceProvider, serviceProvider, ScopedItemOptions.AllScope)); 112 | } 113 | return serviceProvider; 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/PSDataverse/Auth/AuthenticationParameters.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | 3 | using Microsoft.Identity.Client; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Security.Cryptography.X509Certificates; 8 | 9 | public record AuthenticationParameters 10 | { 11 | private const string DefaultClientId = "51f81489-12ee-4a9e-aaae-a2591f45987d"; 12 | 13 | // Power Platform SDK uses "app://58145B91-0C36-4500-8554-080854F2AC97", but according to MSAL docs, localhost is safer 14 | // Read more: https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/using-web-browsers; 15 | private const string DefaultRedirectUrl = "http://localhost"; 16 | 17 | public string Authority { get; set; } 18 | public string Resource { get; set; } 19 | public string ClientId { get; set; } = DefaultClientId; 20 | public string ClientSecret { get; set; } 21 | public string CertificateThumbprint { get; set; } 22 | public StoreName CertificateStoreName { get; set; } 23 | public string Tenant { get; set; } 24 | public IEnumerable Scopes { get; set; } 25 | public bool UseDeviceFlow { get; set; } 26 | public bool UseCurrentUser { get; set; } 27 | public string RedirectUri { get; set; } = DefaultRedirectUrl; 28 | 29 | public IAccount Account { get; set; } 30 | 31 | public static AuthenticationParameters Parse(string connectionString) 32 | { 33 | if (string.IsNullOrEmpty(connectionString)) { throw new ArgumentNullException(nameof(connectionString)); } 34 | string resource = null; 35 | Dictionary dictionary = null; 36 | if (connectionString.IndexOf('=') <= 0) 37 | { 38 | if (connectionString.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) 39 | { 40 | resource = connectionString; 41 | if (!resource.EndsWith("/", StringComparison.OrdinalIgnoreCase)) { resource += "/"; } 42 | dictionary = new Dictionary() { ["resource"] = connectionString, ["integrated security"] = true.ToString() }; 43 | } 44 | else 45 | { 46 | throw new InvalidOperationException("Connection string is invalid. Please check your environment setting in Tools > Options > Xrm Tools"); 47 | } 48 | } 49 | else 50 | { 51 | dictionary = 52 | connectionString.Split([';'], StringSplitOptions.RemoveEmptyEntries) 53 | .ToDictionary( 54 | s => s[..s.IndexOf('=')].Trim(), 55 | s => s[(s.IndexOf('=') + 1)..].Trim(), 56 | StringComparer.OrdinalIgnoreCase); 57 | resource = 58 | dictionary.TryGetValue("resource", out var url) 59 | ? url 60 | : dictionary.TryGetValue("url", out url) 61 | ? url 62 | : throw new ArgumentException("Connection string should contain either Url or Resource. Both are missing."); 63 | } 64 | if (string.IsNullOrEmpty(resource)) 65 | { 66 | throw new ArgumentException("Either Resource or Url is required."); 67 | } 68 | if (!resource.EndsWith("/", StringComparison.OrdinalIgnoreCase)) { resource += "/"; } 69 | 70 | var parameters = new AuthenticationParameters 71 | { 72 | Authority = dictionary.TryGetValue("authority", out var authority) ? authority : null, 73 | ClientId = dictionary.TryGetValue("clientid", out var clientid) ? clientid : DefaultClientId, 74 | RedirectUri = dictionary.TryGetValue("redirecturi", out var redirecturi) ? redirecturi : DefaultRedirectUrl, 75 | Resource = resource, 76 | ClientSecret = dictionary.TryGetValue("clientsecret", out var secret) ? secret : null, 77 | CertificateThumbprint = dictionary.TryGetValue("thumbprint", out var thumbprint) ? thumbprint : null, 78 | Tenant = dictionary.TryGetValue("tenantid", out var tenant) 79 | ? tenant 80 | : dictionary.TryGetValue("tenant", out tenant) 81 | ? tenant 82 | : null, 83 | Scopes = dictionary.TryGetValue("scopes", out var scopes) ? scopes.Split(',') : [new Uri(new Uri(resource, UriKind.Absolute), ".default").ToString()], 84 | UseDeviceFlow = dictionary.TryGetValue("device", out var device) && bool.Parse(device), 85 | UseCurrentUser = dictionary.TryGetValue("integrated security", out var defaultcreds) && bool.Parse(defaultcreds) 86 | }; 87 | if (string.IsNullOrEmpty(parameters.Authority) && !string.IsNullOrEmpty(parameters.Tenant)) 88 | { 89 | parameters.Authority = $"https://login.microsoftonline.com/{parameters.Tenant}/oauth2/authorize"; 90 | } 91 | parameters.CertificateStoreName = ExtractStoreName(dictionary); 92 | return parameters; 93 | } 94 | 95 | private static StoreName ExtractStoreName(Dictionary parameters) 96 | { 97 | 98 | if (parameters.TryGetValue("certificatestore", out var certificateStore) 99 | || parameters.TryGetValue("storename", out certificateStore)) 100 | { 101 | if (Enum.TryParse(certificateStore, true, out StoreName storeName)) 102 | { 103 | return storeName; 104 | } 105 | else 106 | { 107 | //TODO: Log warning. 108 | } 109 | } 110 | return StoreName.My; 111 | } 112 | 113 | public bool IsValid() => !string.IsNullOrWhiteSpace(ClientId) && !string.IsNullOrWhiteSpace(Resource) && 114 | (!string.IsNullOrWhiteSpace(Authority) || !string.IsNullOrWhiteSpace(Tenant)); 115 | 116 | public bool IsUncertainAuthFlow() 117 | => string.IsNullOrWhiteSpace(ClientSecret) && string.IsNullOrWhiteSpace(CertificateThumbprint) && !UseDeviceFlow && IsValid(); 118 | } 119 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Execute/OperationProcessor.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Execute; 2 | 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Globalization; 6 | using System.Net.Http; 7 | using System.Net.Http.Headers; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using Microsoft.Extensions.Logging; 11 | using Newtonsoft.Json.Linq; 12 | using Polly; 13 | using Polly.Registry; 14 | using PSDataverse.Dataverse.Model; 15 | 16 | public class OperationProcessor : Processor//, IBatchProcessor 17 | { 18 | private readonly ILogger log; 19 | private readonly HttpClient httpClient; 20 | private readonly IAsyncPolicy policy; 21 | public string AuthenticationToken 22 | { 23 | set => httpClient.DefaultRequestHeaders.Authorization = 24 | string.IsNullOrEmpty(value) ? null : new AuthenticationHeaderValue("Bearer", value); 25 | } 26 | 27 | public OperationProcessor( 28 | ILogger log, 29 | IHttpClientFactory httpClientFactory, 30 | IReadOnlyPolicyRegistry policyRegistry, 31 | string authenticationToken) : this(log, httpClientFactory, policyRegistry) => AuthenticationToken = authenticationToken; 32 | 33 | public OperationProcessor( 34 | ILogger log, 35 | IHttpClientFactory httpClientFactory, 36 | IReadOnlyPolicyRegistry policyRegistry) 37 | { 38 | this.log = log; 39 | httpClient = httpClientFactory.CreateClient("Dataverse"); 40 | policy = policyRegistry.Get>(Globals.PolicyNameHttp); 41 | } 42 | 43 | public async IAsyncEnumerable ProcessAsync(Batch batch) 44 | { 45 | foreach (var operation in batch.ChangeSet.Operations) 46 | { 47 | //operation.Uri = (new Uri(ServiceUrl, operation.Uri)).ToString(); 48 | //if (operation.Uri.EndsWith("$ref", StringComparison.OrdinalIgnoreCase)) 49 | //{ 50 | // if (operation.Value["@odata.id"] != null) 51 | // { 52 | // operation.Value["@odata.id"] = new Uri(ServiceUrl, operation.Value["@odata.id"].ToString()); 53 | // } 54 | //} 55 | yield return await ExecuteAsync(operation); 56 | } 57 | } 58 | 59 | public async Task ExecuteAsync(Operation operation) 60 | { 61 | if (operation is null) 62 | { throw new ArgumentNullException(nameof(operation)); } 63 | 64 | if (!operation.Uri.StartsWith("http", StringComparison.OrdinalIgnoreCase)) 65 | { 66 | operation.Uri = new Uri(httpClient.BaseAddress, operation.Uri).ToString(); 67 | } 68 | 69 | log.LogDebug($"Executing operation {operation.Method} {operation.Uri}..."); 70 | var response = await policy.ExecuteAsync(() => httpClient.SendAsync(operation, CancellationToken.None)); 71 | log.LogDebug($"Dataverse: {(int)response.StatusCode} {response.ReasonPhrase}"); 72 | 73 | if (response.IsSuccessStatusCode) 74 | { return response; } 75 | 76 | await ThrowOperationExceptionAsync(operation, response); 77 | return null; 78 | } 79 | 80 | public async Task ExecuteAsync(Operation operation) 81 | { 82 | if (operation is null) 83 | { throw new ArgumentNullException(nameof(operation)); } 84 | 85 | if (!operation.Uri.StartsWith("http", StringComparison.OrdinalIgnoreCase)) 86 | { 87 | operation.Uri = new Uri(httpClient.BaseAddress, operation.Uri).ToString(); 88 | } 89 | 90 | log.LogDebug($"Executing operation {operation.Method} {operation.Uri}..."); 91 | var response = await policy.ExecuteAsync(() => httpClient.SendAsync(operation, CancellationToken.None)); 92 | log.LogDebug($"Dataverse: {(int)response.StatusCode} {response.ReasonPhrase}"); 93 | 94 | if (response.IsSuccessStatusCode) 95 | { return response; } 96 | 97 | await ThrowOperationExceptionAsync(operation, response); 98 | return null; 99 | } 100 | 101 | private async Task ThrowOperationExceptionAsync(Operation operation, HttpResponseMessage response) 102 | { 103 | operation.RunCount++; 104 | var error = await ExtractError(response); 105 | throw CreateOperationException( 106 | "operationerror", 107 | operation, 108 | new OperationResponse( 109 | response.StatusCode, 110 | string.IsNullOrEmpty(operation.ContentId) ? Guid.Empty.ToString() : operation.ContentId, 111 | error)); 112 | } 113 | 114 | private async Task ThrowOperationExceptionAsync(Operation operation, HttpResponseMessage response) 115 | { 116 | operation.RunCount++; 117 | var error = await ExtractError(response); 118 | throw CreateOperationException( 119 | "operationerror", 120 | operation, 121 | new OperationResponse( 122 | response.StatusCode, 123 | string.IsNullOrEmpty(operation.ContentId) ? Guid.Empty.ToString() : operation.ContentId, 124 | error)); 125 | } 126 | 127 | private async Task ExtractError(HttpResponseMessage response) 128 | { 129 | if (response.Content == null) 130 | { 131 | log.LogWarning("Dynamics 365 returned non-success without conntent!"); 132 | return null; 133 | } 134 | var responseContent = await response.Content.ReadAsStringAsync(); 135 | response.Content.Dispose(); 136 | if (!string.IsNullOrEmpty(responseContent) && response.Content.Headers.ContentType?.MediaType == "application/json") 137 | { 138 | var responseJson = JObject.Parse(responseContent); 139 | var errorJson = responseJson.SelectToken("error"); 140 | if (errorJson == null) 141 | { 142 | return new OperationError 143 | { 144 | Code = responseJson["ErrorCode"].ToString(), 145 | // Ignore ErrorMessage because it is always the same as Message. 146 | Message = responseJson["Message"].ToString(), 147 | Type = responseJson["ExceptionType"].ToString(), 148 | StackTrace = responseJson["StackTrace"].ToString() 149 | }; 150 | } 151 | return errorJson.ToObject(); 152 | } 153 | return new OperationError 154 | { 155 | Code = ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture), 156 | Message = string.IsNullOrWhiteSpace(responseContent) ? response.ReasonPhrase : responseContent 157 | }; 158 | } 159 | 160 | private OperationException CreateOperationException( 161 | string batchId, 162 | Operation operation, 163 | OperationResponse response) 164 | { 165 | var entityName = ExtractEntityName(operation); 166 | var errorMessage = $"{response.Error?.Code} {response.Error?.Message}"; 167 | return new OperationException(errorMessage) 168 | { 169 | BatchId = batchId, 170 | Operation = operation, 171 | Error = response?.Error, 172 | EntityName = entityName, 173 | }; 174 | } 175 | 176 | private OperationException CreateOperationException( 177 | string batchId, 178 | Operation operation, 179 | OperationResponse response) 180 | { 181 | var entityName = ExtractEntityName(operation); 182 | var errorMessage = $"{response.Error?.Code} {response.Error?.Message}"; 183 | return new OperationException(errorMessage) 184 | { 185 | BatchId = batchId, 186 | Operation = operation, 187 | Error = response?.Error, 188 | EntityName = entityName, 189 | }; 190 | } 191 | 192 | } 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Table of Contents 2 | * [What is PSDataverse](#what-is-psdataverse) 3 | * [Features](#features) 4 | * [How to install](#how-to-install) 5 | * [How to use](#how-to-use) 6 | * [Connecting to Dataverse](#connecting-to-dataverse) 7 | * [Sending operations to Dataverse](#sending-operations-to-dataverse) 8 | 9 | # What is PSDataverse? 10 | PSDataverse is a PowerShell module that brings Dataverse's Web API to PowerShell 7+ with features like piping, batching and more. It is designed with ease-of-use and performance in mind and follows the patterns of native PowerShell cmdlets to play nicely with other modules. 11 | 12 | # Features 13 | * Securely connect to Dataverse. 14 | * Supports bacthing. 15 | * Supports parallelism. 16 | * Automatically reconnects when authentication token is about to expire. 17 | * Enhanced pipeline support (accepts different data types as input and emits responses to the pipeline). 18 | * Automatic wait-and-retry for transient errors by default. 19 | * Repects throttling data sent by Dataverse. 20 | * Does not hide the response sent back by Dataverse. 21 | 22 | # How to install 23 | You can install the [PSDataverse module directly from PowerShell Gallery](https://www.powershellgallery.com/packages/PSDataverse) using the following command 24 | ```powershell 25 | Install-Module -Name PSDataverse 26 | ``` 27 | 28 | As an alternative, you can also download the dll and module or clone the repository and build it locally. After that, to import the module to your current session you can run the following command. 29 | 30 | ```powershell 31 | if (-not(Get-Module -ListAvailable -Name MigrationModule)) { 32 | Import-Module .\PSDataverse.psd1 33 | } 34 | ``` 35 | 36 | > **NOTE!** 37 | > PSDataverse is a hybrid module that is a mix of PSDataverse.dll and PSDataverse.psd1 module definition. Only the commands that made more sense to be implemented as binary are included in the dll, and the rest of the implementation is done using PowerShell language. 38 | 39 | # How to use 40 | The first thing to do is to connect to your Dataverse environment using `Connect-Dataverse` cmdlet. Currently there are three ways that you can connect: 41 | * Using a Client ID (aka Application ID) and a Client Password 42 | * Using a Client ID and a certificate that you have installed in OS's certificate store 43 | * Using a device authentication flow (interactive login) 44 | 45 | ## Connecting to Dataverse 46 | 47 | **Example 1 - Connecting to Dataverse using a client ID and a client certificate installed in certificate store.** 48 | ```powershell 49 | Connect-Dataverse "authority=https://login.microsoftonline.com//oauth2/authorize;clientid=;thumbprint=;resource=https://.crm4.dynamics.com/" 50 | ``` 51 | 52 | **Example 2 - Connecting to Dataverse using a client ID and a client secret.** 53 | ```powershell 54 | Connect-Dataverse "authority=https://login.microsoftonline.com//oauth2/authorize;clientid=;clientsecret=;resource=https://.crm4.dynamics.com/" 55 | ``` 56 | 57 | **Example 3 - Connecting to Dataverse using device authentication flow.** 58 | ```powershell 59 | Connect-Dataverse "authority=https://login.microsoftonline.com//oauth2/authorize;clientid=1950a258-227b-4e31-a9cf-717495945fc2;device=true;resource=https://.crm4.dynamics.com/" -InformationAction Continue 60 | ``` 61 | When you run the above command, a message like the following will be printed in the console, and you just need to do what is asked. After that, you will be prompted to use your credentials and that's it. 62 | ``` 63 | To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code CSPUJ9S7K to authenticate. 64 | ``` 65 | This is the easiest way to log in, when your just need to do ad-hoc operations. 66 | 67 | > [!NOTE] 68 | > 69 | > For any of first two examples to work you need an application user in your Power Platform environment. To learn how to create an application user, please read the following article from the official documentation: [Manage application users in the Power Platform admin center](https://docs.microsoft.com/en-us/power-platform/admin/manage-application-users). 70 | 71 | The third one is using a wellknown client id, but if you want you can also use the client id of your own app registration. If you wish to use your own app registration for device authentication flow, you will need to enable "Allow public client flows" for your app registration. 72 | 73 | After connecting to the Dataverse, you can send any number of operations to your Dataverse environment. If the authentication expires, PSDataverse will automatically reauthenticate behind the scene. 74 | 75 | ## Sending operations to Dataverse 76 | 77 | Let's look at a simple operation. 78 | 79 | **Example 1: Running a global action using piping** 80 | ```powershell 81 | @{Uri="WhoAmI"} | Send-DataverseOperation 82 | ``` 83 | Or: 84 | ```powershell 85 | Send-DataverseOperation @{Uri="WhoAmI"} 86 | ``` 87 | 88 | This will result in an OperationResponse like the following: 89 | 90 | ``` 91 | ContentId : 92 | Content : {"@odata.context":"https://yourdynamic-environment.crm4.dynamics.com/api/data/v9.2/$metadata#Microsoft.Dy 93 | namics.CRM.WhoAmIResponse","BusinessUnitId":"6f202e6c-e471-ec11-8941-000d3adf0002","UserId":"88057198-a9b1 94 | -ec11-9840-00567ab5c181","OrganizationId":"e34c95a5-f34c-430c-a05e-a23437e5b9fa"} 95 | Error : 96 | Headers : {[Cache-Control, no-cache], [x-ms-service-request-id, c9af118d-b483-48c8-887a-baa4de679bf,9b44cd2f-d34f-4 97 | 908-bed4-edc12c44857d], [Set-Cookie, ARRAffinity=49fcec1e1d435e207a47447f2a47260b469a63eb4e69bdcc0610e04c1 98 | 24a7f15; domain=yourdynamic-environment.crm4.dynamics.com; path=/; secure; HttpOnly], [Strict-Transport-S 99 | ecurity, max-age=31536000; includeSubDomains]…} 100 | StatusCode : OK 101 | ``` 102 | 103 | You can always see the original headers and status code from dynamics. If there's any error, it will be reflected in 'Error' property. The most important property that you will often need is the 'Content' that includes the payload in JSON format. 104 | 105 | The following command will have exactly the same result, but it is sending a string which is considered as JSON. 106 | ```powershell 107 | '{"Uri":"WhoAmI"}' | Send-DataverseOperation 108 | ``` 109 | or: 110 | ```powershell 111 | Send-DataverseOperation '{"Uri":"WhoAmI"}' 112 | ``` 113 | 114 | 115 | > **ℹ NOTE** 116 | > When the input is a Hashtable like object, it will be converted to JSON equivalent before sending to Dataverse. To have more control over the conversion to JSON, it is recommended to use the native `[ConvertTo-Json](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/convertto-json)` before Send-DataverseOperation. 117 | 118 | **Example 2: Running a global action using piping and display the returned object** 119 | 120 | Now, let's see how we can get to the 'Content' property, convert it to a PowerShell object and then display it as a list, all in one line. 121 | 122 | ```powershell 123 | @{Uri="WhoAmI"} | Send-DataverseOperation | select -ExpandProperty Content | ConvertFrom-Json | Format-List 124 | ``` 125 | 126 | This will reult in the following output: 127 | 128 | ``` 129 | @odata.context : https://helloworld.crm4.dynamics.com/api/data/v9.2/$metadata#Microsoft.Dynamics.CRM.WhoA 130 | mIResponse 131 | BusinessUnitId : 6f202e6c-e471-ec11-8941-000d3adf0002 132 | UserId : 88057198-a9b1-ec11-9840-00567ab5c181 133 | OrganizationId : e34c95a5-f34c-430c-a05e-a23437e5b9fa 134 | ``` 135 | 136 | **Example 3: Running a global action and accessing the result** 137 | 138 | When the result is converted to an object, you can access any of the properties like any other PowerShell object. 139 | 140 | ```powershell 141 | $whoAmI = ConvertTo-Json ([pscustomobject]@{Uri="WhoAmI"}) | Send-DataverseOperation | ConvertFrom-Json 142 | Write-Host $whoAmI.UserId 143 | ``` 144 | The above example sends a WhoAmI request to the Dataverse and gets back the result. If you check carefully this is what happens in each step: 145 | 1. An operation is defined as a Hashtable i.e. `@{Uri="WhoAmI";Method="GET"}` and using `ConvertTo-Json` this Hashtable is converted to JSON. 146 | 2. The operation is piped to `Send-DataverseOperation` that sends the operation to Dataverse and gets back the result. 147 | 3. The result of `Send-Operation` is then converted back to a Hashtable. The table will contain three properties as per documentation. BusinessUnitId, UserId, and OrganizationId 148 | 4. The second line is just printing the UserId to the host. 149 | 150 | # Status 151 | 152 | [![PSScriptAnalyzer](https://github.com/rezanid/PSDataverse/actions/workflows/powershell.yml/badge.svg)](https://github.com/rezanid/PSDataverse/actions/workflows/powershell.yml) 153 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Model/OperationResponse.cs: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | namespace PSDataverse.Dataverse.Model; 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Globalization; 7 | using System.IO; 8 | using System.Linq; 9 | using System.Net; 10 | using System.Net.Http; 11 | using System.Text; 12 | using Newtonsoft.Json.Linq; 13 | 14 | public class OperationResponse 15 | { 16 | public string? ContentId { get; set; } 17 | public HttpStatusCode? StatusCode { get; set; } 18 | public string? Content { get; set; } 19 | public OperationError? Error { get; set; } 20 | public Dictionary? Headers { get; set; } 21 | 22 | #region ctors 23 | public OperationResponse() { } 24 | 25 | public OperationResponse(HttpStatusCode statusCode, string? contentId) 26 | { 27 | StatusCode = statusCode; 28 | ContentId = contentId; 29 | } 30 | 31 | public OperationResponse(HttpStatusCode statusCode, string? contentId, OperationError? error) 32 | : this(statusCode, contentId) => Error = error; 33 | 34 | public OperationResponse(HttpStatusCode statusCode, string? contentId, string content) 35 | : this(statusCode, contentId) => Content = content; 36 | 37 | public OperationResponse(HttpStatusCode statusCode, string? contentId, OperationError? error, Dictionary? headers) 38 | : this(statusCode, contentId, error) => Headers = headers; 39 | 40 | public OperationResponse(HttpStatusCode statusCode, string? contentId, string content, Dictionary? headers) 41 | : this(statusCode, contentId, content) => Headers = headers; 42 | 43 | public OperationResponse(HttpStatusCode statusCode, string? contentId, Dictionary? headers) 44 | : this(statusCode, contentId) => Headers = headers; 45 | 46 | public OperationResponse(HttpStatusCode statusCode, string? contentId, OperationError? error, string? content, Dictionary? headers) 47 | : this(statusCode, contentId, error, headers) => Content = content; 48 | #endregion 49 | 50 | public static OperationResponse? From(HttpResponseMessage message) 51 | { 52 | if (message == null) 53 | { throw new ArgumentNullException(nameof(message)); } 54 | if (message.Content == null) 55 | { throw new InvalidOperationException($"{nameof(message)}'s Content cannot be null"); } 56 | return new OperationResponse( 57 | statusCode: message.StatusCode, 58 | contentId: message.Headers.TryGetValues("Content-ID", out var values) ? string.Join(',', values) : "", 59 | error: (int)message.StatusCode >= 400 ? System.Text.Json.JsonSerializer.Deserialize(message!.Content!.ToString() ?? "") : null, 60 | content: message.StatusCode == HttpStatusCode.OK ? message!.Content!.ReadAsStringAsync().Result : null, 61 | headers: message.Headers.ToDictionary(h => h.Key, h => string.Join(',', h.Value))); 62 | } 63 | 64 | public static OperationResponse? Parse(StringReader reader) 65 | { 66 | //--changesetresponse_66ffbfa0-8e37-4eb1-b843-1b4260b0235e 67 | var buffer = reader.ReadLine(); 68 | if (buffer == null) 69 | { return null; } 70 | if (buffer.StartsWith("--batchresponse_", StringComparison.OrdinalIgnoreCase) && buffer.EndsWith("--", StringComparison.OrdinalIgnoreCase)) 71 | { 72 | // end of batch 73 | return null; 74 | } 75 | if (!buffer.StartsWith("--changesetresponse_", StringComparison.OrdinalIgnoreCase)) 76 | { 77 | throw new ParseException( 78 | string.Format( 79 | CultureInfo.InvariantCulture, 80 | "Expected \"--changesetresponse_\", but found \"{0}\".", buffer[..20])); 81 | } 82 | if (buffer.EndsWith("--", StringComparison.OrdinalIgnoreCase)) 83 | { 84 | // end of changeset 85 | buffer = reader.ReadLine(); 86 | if ((!buffer?.StartsWith("--batchresponse_", StringComparison.OrdinalIgnoreCase) ?? false) || (!buffer?.EndsWith("--", StringComparison.OrdinalIgnoreCase) ?? false)) 87 | { 88 | throw new ParseException( 89 | string.Format( 90 | CultureInfo.InvariantCulture, 91 | "Expected \"--batchresponse_--\", but found \"{0}\".", buffer?[..16])); 92 | } 93 | return null; 94 | } 95 | 96 | //Content-Type: application/http 97 | //Content-Transfer-Encoding: binary 98 | //Content-ID: 1 99 | /*while ((buffer = reader.ReadLine()) != string.Empty) 100 | { 101 | if (buffer.StartsWith("Content-ID:")) 102 | { 103 | contentId = int.Parse(buffer.Substring(11).TrimStart()); 104 | } 105 | }*/ 106 | var headers = ParseHeaders(reader); 107 | var contentId = ""; 108 | headers?.TryGetValue("Content-ID", out contentId); 109 | 110 | //HTTP/1.1 412 Precondition Failed 111 | //*********###*xxxxxxxxxxxxxxxxxxxxx 112 | buffer = reader.ReadLine(); 113 | if (string.IsNullOrEmpty(buffer)) 114 | { return null; } 115 | var status = int.Parse(buffer.Substring(9, 3), NumberStyles.None, CultureInfo.InvariantCulture); 116 | // Reason text could also be extracted by `buffer.Substring(13)`; 117 | 118 | //Content-Type: application/json; odata.metadata=minimal 119 | //OData-Version: 4.0 120 | /*while ((buffer = reader.ReadLine()) != string.Empty) 121 | { 122 | // Skip all the headers. 123 | }*/ 124 | headers = ParseHeaders(reader); 125 | 126 | if (status is > 199 and < 300) 127 | { 128 | //HTTP/1.1 204 No Content 129 | reader.ReadLine(); 130 | return new OperationResponse((HttpStatusCode)status, contentId, headers); 131 | } 132 | else 133 | { 134 | // Example 1: 135 | // "HTTP/1.1 412 Precondition Failed\r\n" 136 | // Example 2: 137 | // "HTTP/1.1 429 Unknown Status Code\r\n" 138 | var content = new StringBuilder(); 139 | buffer = reader.ReadLine(); 140 | while (buffer != null && !buffer.StartsWith("--changesetresponse_", StringComparison.OrdinalIgnoreCase)) 141 | { 142 | content.AppendLine(buffer); 143 | buffer = reader.ReadLine(); 144 | } 145 | var json = JObject.Parse(content.ToString()); 146 | var error = (status == 429) ? 147 | ThrottlingRateLimitExceededExceptionToOperationError(json) : 148 | json?.SelectToken("error")?.ToObject(); 149 | return new OperationResponse((HttpStatusCode)status, contentId, error, headers); 150 | } 151 | } 152 | 153 | public bool IsRecoverable() 154 | { 155 | if (Error == null) 156 | { return false; } 157 | return 158 | Error.Message == "Generic SQL error." || 159 | Error.Code == "429"; 160 | } 161 | 162 | private static Dictionary? ParseHeaders( 163 | StringReader reader, 164 | bool skipValues = false) 165 | { 166 | //Example 1: 167 | // Content-Type: application/http 168 | // Content-Transfer-Encoding: binary 169 | // Content-ID: 1 170 | // 171 | // Example 2: 172 | // Content-Type: application/json; odata.metadata=minimal 173 | // OData-Version: 4.0 174 | string? buffer; 175 | if (skipValues) 176 | { 177 | while (reader?.ReadLine() != string.Empty) 178 | { }; 179 | return null; 180 | } 181 | var headers = new Dictionary(); 182 | while (!string.IsNullOrEmpty(buffer = reader?.ReadLine())) 183 | { 184 | var separatorPos = buffer.IndexOf(':'); 185 | var key = buffer[..(separatorPos > 0 ? separatorPos : buffer.Length)]; 186 | var value = buffer[(separatorPos + 1)..].Trim(); 187 | if (headers.TryGetValue(key, out var existingValue)) 188 | { 189 | value = existingValue + "; " + value; 190 | } 191 | headers[key] = value; 192 | } 193 | 194 | return headers; 195 | } 196 | 197 | private static OperationError ThrottlingRateLimitExceededExceptionToOperationError( 198 | JObject json) => new() 199 | { 200 | Code = json["ErrorCode"]?.ToString(), 201 | // Ignore ErrorMessage because it is always the same as Message. 202 | Message = json["Message"]?.ToString(), 203 | Type = json["ExceptionType"]?.ToString(), 204 | StackTrace = json["StackTrace"]?.ToString() 205 | }; 206 | } 207 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Commands/ConnectDataverseNewCmdlet.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | using System; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Management.Automation; 6 | using System.Net.Http; 7 | using System.Security; 8 | using System.Security.Cryptography.X509Certificates; 9 | using System.Text.RegularExpressions; 10 | using System.Threading.Tasks; 11 | using Microsoft.Identity.Client; 12 | 13 | [Cmdlet(VerbsCommunications.Connect, "DataverseNew", SupportsShouldProcess = true)] 14 | public class ConnectDataverseNewCmdlet : PSCmdlet 15 | { 16 | private const string AuthorityBase = "https://login.microsoftonline.com/"; 17 | private static readonly HttpClient HttpClient = new HttpClient(); 18 | 19 | [Parameter(Position = 0, Mandatory = true)] 20 | public string EnvironmentUrl { get; set; } 21 | 22 | [Parameter(ParameterSetName = "DeviceCode", Mandatory = true)] 23 | public SwitchParameter DeviceCode { get; set; } 24 | 25 | [Parameter(ParameterSetName = "Interactive", Mandatory = true)] 26 | public SwitchParameter Interactive { get; set; } 27 | 28 | [Parameter(ParameterSetName = "ClientCredentials", Mandatory = true)] 29 | public string ClientId { get; set; } 30 | 31 | [Parameter(ParameterSetName = "ClientCredentials", Mandatory = false)] 32 | public SecureString ClientSecret { get; set; } 33 | 34 | [Parameter(ParameterSetName = "ClientCredentials", Mandatory = false)] 35 | public string TenantId { get; set; } 36 | 37 | [Parameter(ParameterSetName = "ClientCertificate", Mandatory = true)] 38 | public string CertificateThumbprint { get; set; } 39 | 40 | [Parameter(ParameterSetName = "ClientCertificate", Mandatory = true)] 41 | public string CertificateFilePath { get; set; } 42 | 43 | [Parameter(ParameterSetName = "ClientCertificate", Mandatory = false)] 44 | public SecureString CertificatePassword { get; set; } 45 | 46 | [Parameter(Mandatory = false)] 47 | public SwitchParameter UseCachedAccount { get; set; } 48 | 49 | [Parameter(Mandatory = false)] 50 | public SwitchParameter ForceLogin { get; set; } 51 | 52 | private IConfidentialClientApplication confidentialClientApp; 53 | private IPublicClientApplication publicClientApp; 54 | private AuthenticationResult authResult; 55 | 56 | protected override void ProcessRecord() 57 | { 58 | if (string.IsNullOrEmpty(TenantId)) 59 | { 60 | WriteVerbose("No TenantId provided. Attempting auto-discovery..."); 61 | TenantId = DiscoverTenantId(EnvironmentUrl).GetAwaiter().GetResult(); 62 | WriteVerbose($"Discovered TenantId: {TenantId}"); 63 | } 64 | 65 | authResult = AuthenticateAsync().GetAwaiter().GetResult(); 66 | 67 | if (authResult != null) 68 | { 69 | SessionState.PSVariable.Set("DataverseAuthToken", authResult.AccessToken); 70 | SessionState.PSVariable.Set("DataverseEnvironment", EnvironmentUrl); 71 | SessionState.PSVariable.Set("DataverseTenantId", TenantId); 72 | WriteVerbose($"Successfully authenticated to Dataverse: {EnvironmentUrl}"); 73 | WriteObject(new { authResult.AccessToken, authResult.ExpiresOn, TenantId }); 74 | } 75 | } 76 | 77 | private async Task DiscoverTenantId(string environmentUrl) 78 | { 79 | try 80 | { 81 | var requestUri = new Uri(new Uri(environmentUrl), "api/data/v9.0/"); 82 | using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); 83 | using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); 84 | 85 | if (response.StatusCode == System.Net.HttpStatusCode.Found) // 302 Redirect 86 | { 87 | if (response.Headers.Location != null) 88 | { 89 | var match = Regex.Match(response.Headers.Location.AbsoluteUri, @"https://login\.microsoftonline\.com/([^/]+)/"); 90 | if (match.Success) 91 | { 92 | return match.Groups[1].Value; 93 | } 94 | } 95 | } 96 | } 97 | catch (Exception ex) 98 | { 99 | WriteWarning($"Failed to auto-discover TenantId: {ex.Message}"); 100 | } 101 | 102 | throw new InvalidOperationException("Tenant ID auto-discovery failed. Please provide a TenantId explicitly."); 103 | } 104 | 105 | private async Task AuthenticateAsync() 106 | { 107 | var scopes = new[] { $"{EnvironmentUrl}/.default" }; 108 | 109 | if (DeviceCode) 110 | { 111 | publicClientApp ??= PublicClientApplicationBuilder 112 | .Create(ClientId) 113 | .WithAuthority($"{AuthorityBase}{TenantId}") 114 | .WithDefaultRedirectUri() 115 | .Build(); 116 | 117 | return await publicClientApp.AcquireTokenWithDeviceCode(scopes, async deviceCode => 118 | { 119 | WriteInformation(new($"Go to {deviceCode.VerificationUrl} and enter the code: {deviceCode.UserCode}", "dataverse")); 120 | }).ExecuteAsync(); 121 | } 122 | else if (Interactive) 123 | { 124 | publicClientApp ??= PublicClientApplicationBuilder 125 | .Create(ClientId) 126 | .WithAuthority($"{AuthorityBase}{TenantId}") 127 | .WithDefaultRedirectUri() 128 | .Build(); 129 | 130 | return await publicClientApp.AcquireTokenInteractive(scopes).ExecuteAsync(); 131 | } 132 | else if (!string.IsNullOrEmpty(ClientId) && ClientSecret != null) 133 | { 134 | confidentialClientApp ??= ConfidentialClientApplicationBuilder 135 | .Create(ClientId) 136 | .WithClientSecret(ClientSecret.ConvertToUnsecureString()) 137 | .WithAuthority($"{AuthorityBase}{TenantId}") 138 | .Build(); 139 | 140 | return await confidentialClientApp.AcquireTokenForClient(scopes).ExecuteAsync(); 141 | } 142 | else if (!string.IsNullOrEmpty(ClientId) && (!string.IsNullOrEmpty(CertificateThumbprint) || !string.IsNullOrEmpty(CertificateFilePath))) 143 | { 144 | X509Certificate2 cert = LoadCertificate(); 145 | confidentialClientApp ??= ConfidentialClientApplicationBuilder 146 | .Create(ClientId) 147 | .WithCertificate(cert) 148 | .WithAuthority($"{AuthorityBase}{TenantId}") 149 | .Build(); 150 | 151 | return await confidentialClientApp.AcquireTokenForClient(scopes).ExecuteAsync(); 152 | } 153 | 154 | throw new InvalidOperationException("Invalid authentication method."); 155 | } 156 | 157 | private X509Certificate2 LoadCertificate() 158 | { 159 | if (!string.IsNullOrEmpty(CertificateThumbprint)) 160 | { 161 | using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); 162 | store.Open(OpenFlags.ReadOnly); 163 | var cert = store.Certificates 164 | .Find(X509FindType.FindByThumbprint, CertificateThumbprint, false) 165 | .OfType() 166 | .FirstOrDefault(); 167 | 168 | if (cert == null) 169 | { 170 | throw new ArgumentException($"Certificate with thumbprint {CertificateThumbprint} not found."); 171 | } 172 | 173 | return cert; 174 | } 175 | else if (!string.IsNullOrEmpty(CertificateFilePath)) 176 | { 177 | if (!File.Exists(CertificateFilePath)) 178 | { 179 | throw new FileNotFoundException("Certificate file not found.", CertificateFilePath); 180 | } 181 | 182 | return new X509Certificate2( 183 | CertificateFilePath, 184 | CertificatePassword?.ConvertToUnsecureString(), 185 | X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.EphemeralKeySet); 186 | } 187 | 188 | throw new ArgumentException("No valid certificate parameters provided."); 189 | } 190 | } 191 | 192 | public static class SecureStringExtensions 193 | { 194 | public static string ConvertToUnsecureString(this SecureString secureString) 195 | { 196 | if (secureString == null) 197 | { 198 | return null; 199 | } 200 | 201 | var unmanagedString = IntPtr.Zero; 202 | try 203 | { 204 | unmanagedString = System.Runtime.InteropServices.Marshal.SecureStringToGlobalAllocUnicode(secureString); 205 | return System.Runtime.InteropServices.Marshal.PtrToStringUni(unmanagedString); 206 | } 207 | finally 208 | { 209 | System.Runtime.InteropServices.Marshal.ZeroFreeGlobalAllocUnicode(unmanagedString); 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/PSDataverse/Auth/DelegatingAuthenticator.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Auth; 2 | 3 | using Microsoft.Identity.Client; 4 | using Microsoft.Identity.Client.Extensions.Msal; 5 | using System; 6 | using System.Linq; 7 | using System.Security.Cryptography.X509Certificates; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using System.Diagnostics; 11 | 12 | internal abstract class DelegatingAuthenticator : IAuthenticator 13 | { 14 | private AuthenticationParameters lastParameters; 15 | private IClientApplicationBase lastClientApp; 16 | 17 | public IAuthenticator NextAuthenticator { get; set; } 18 | 19 | public virtual async Task AuthenticateAsync( 20 | AuthenticationParameters parameters, Action onMessageForUser = default, CancellationToken cancellationToken = default) 21 | { 22 | var app = await GetClientAppAsync(parameters, cancellationToken); 23 | var accounts = await app.GetAccountsAsync(); 24 | var firstAccount = accounts.FirstOrDefault(); 25 | 26 | try 27 | { 28 | return await app.AcquireTokenSilent(parameters.Scopes, firstAccount) 29 | .ExecuteAsync(cancellationToken) 30 | .ConfigureAwait(false); 31 | } 32 | catch (MsalUiRequiredException) 33 | { 34 | try 35 | { 36 | var phwnd = Process.GetCurrentProcess().MainWindowHandle; 37 | if (app.AsPublicClient() is PublicClientApplication appClient) 38 | { 39 | return await appClient.AcquireTokenInteractive(parameters.Scopes) 40 | .WithAccount(accounts.FirstOrDefault()) 41 | .WithPrompt(Prompt.SelectAccount) 42 | .WithParentActivityOrWindow(phwnd) 43 | .ExecuteAsync(cancellationToken) 44 | .ConfigureAwait(false); 45 | } 46 | } 47 | catch (MsalException) 48 | { 49 | //TODO: Logging 50 | } 51 | } 52 | catch (Exception) 53 | { 54 | //TODO: Logging. 55 | } 56 | 57 | return null; 58 | } 59 | 60 | public abstract bool CanAuthenticate(AuthenticationParameters parameters); 61 | 62 | public virtual async Task GetClientAppAsync(AuthenticationParameters parameters, CancellationToken cancellationToken) 63 | { 64 | if (lastParameters == parameters && lastClientApp != null) { return lastClientApp; } 65 | lastParameters = parameters; 66 | 67 | if (!parameters.UseDeviceFlow & ( 68 | !string.IsNullOrEmpty(parameters.CertificateThumbprint) || 69 | !string.IsNullOrEmpty(parameters.ClientSecret))) 70 | { 71 | return lastClientApp = CreateConfidentialClient( 72 | parameters.Authority, 73 | parameters.ClientId, 74 | parameters.ClientSecret, 75 | FindCertificate(parameters.CertificateThumbprint, parameters.CertificateStoreName), 76 | parameters.RedirectUri, 77 | parameters.Tenant); 78 | } 79 | 80 | return lastClientApp = await CreatePublicClientAsync( 81 | parameters.Authority, 82 | parameters.ClientId, 83 | parameters.RedirectUri, 84 | parameters.Tenant); 85 | } 86 | 87 | public async Task TryAuthenticateAsync( 88 | AuthenticationParameters parameters, Action onMessageForUser = default, CancellationToken cancellationToken = default) 89 | { 90 | if (CanAuthenticate(parameters)) 91 | { 92 | return await AuthenticateAsync(parameters, onMessageForUser, cancellationToken).ConfigureAwait(false); 93 | } 94 | 95 | if (NextAuthenticator != null) 96 | { 97 | return await NextAuthenticator.TryAuthenticateAsync(parameters, onMessageForUser, cancellationToken).ConfigureAwait(false); 98 | } 99 | 100 | return null; 101 | } 102 | 103 | private static IConfidentialClientApplication CreateConfidentialClient( 104 | string authority, 105 | string clientId = null, 106 | string clientSecret = null, 107 | X509Certificate2 certificate = null, 108 | string redirectUri = null, 109 | string tenantId = null) 110 | { 111 | var builder = ConfidentialClientApplicationBuilder.Create(clientId); 112 | 113 | builder = builder.WithAuthority(authority); 114 | 115 | if (!string.IsNullOrEmpty(clientSecret)) 116 | { builder = builder.WithClientSecret(clientSecret); } 117 | 118 | if (certificate != null) 119 | { builder = builder.WithCertificate(certificate); } 120 | 121 | if (!string.IsNullOrEmpty(redirectUri)) 122 | { builder = builder.WithRedirectUri(redirectUri); } 123 | 124 | if (!string.IsNullOrEmpty(tenantId)) 125 | { builder = builder.WithTenantId(tenantId); } 126 | 127 | var client = builder.WithLogging((level, message, pii) => 128 | { 129 | //TODO: Replace the following line when logging is in-place. 130 | //PartnerSession.Instance.DebugMessages.Enqueue($"[MSAL] {level} {message}"); 131 | }).Build(); 132 | 133 | return client; 134 | } 135 | 136 | private static async Task CreatePublicClientAsync( 137 | string authority, 138 | string clientId = null, 139 | string redirectUri = null, 140 | string tenantId = null) 141 | { 142 | var builder = PublicClientApplicationBuilder.Create(clientId); 143 | 144 | if (!string.IsNullOrEmpty(authority)) 145 | { 146 | builder = builder.WithAuthority(authority); 147 | } 148 | 149 | if (!string.IsNullOrEmpty(redirectUri)) 150 | { 151 | builder = builder.WithRedirectUri(redirectUri); 152 | } 153 | 154 | if (!string.IsNullOrEmpty(tenantId)) 155 | { 156 | builder = builder.WithTenantId(tenantId); 157 | } 158 | 159 | var publicClientApp = builder.WithLogging((level, message, pii) => 160 | { 161 | // TODO: Replace the following line when logging is in-place. 162 | // PartnerSession.Instance.DebugMessages.Enqueue($"[MSAL] {level} {message}"); 163 | }).Build(); 164 | 165 | var storageProperties = new StorageCreationPropertiesBuilder("msal_cache.dat", 166 | MsalCacheHelper.UserRootDirectory) 167 | // No need to support non-Windows platforms yet. 168 | //.WithLinuxKeyring( 169 | // "com.vs.xrmtools", MsalCacheHelper.LinuxKeyRingDefaultCollection, "Xrm Tools Credentials", 170 | // new("Version", "1"), new("Product", "Xrm Tools")) 171 | //.WithMacKeyChain("xrmtools_msal_service", "xrmtools_msa_account") 172 | .Build(); 173 | 174 | // Create and register the cache helper to enable persistent caching 175 | var cacheHelper = await MsalCacheHelper.CreateAsync(storageProperties).ConfigureAwait(false); 176 | //TODO: persisting token cache didn't work well in Win RT. 177 | //cacheHelper.VerifyPersistence(); 178 | cacheHelper.RegisterCache(publicClientApp.UserTokenCache); 179 | 180 | return publicClientApp; 181 | } 182 | 183 | public static X509Certificate2 FindCertificate( 184 | string thumbprint, 185 | StoreName storeName) 186 | { 187 | if (thumbprint == null) 188 | { 189 | return null; 190 | } 191 | 192 | var source = new StoreLocation[2] { StoreLocation.CurrentUser, StoreLocation.LocalMachine }; 193 | X509Certificate2 certificate = null; 194 | if (source.Any(storeLocation => TryFindCertificatesInStore(thumbprint, storeLocation, storeName, out certificate))) 195 | { 196 | return certificate; 197 | } 198 | return null; 199 | } 200 | 201 | private static bool TryFindCertificatesInStore(string thumbprint, StoreLocation location, StoreName storeName, out X509Certificate2 certificate) 202 | { 203 | X509Store store = null; 204 | 205 | if (string.IsNullOrEmpty(thumbprint)) 206 | { 207 | throw new ArgumentNullException(nameof(thumbprint)); 208 | } 209 | 210 | try 211 | { 212 | store = new X509Store(storeName, location); 213 | store.Open(OpenFlags.ReadOnly); 214 | 215 | certificate = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false) 216 | .OfType() 217 | .FirstOrDefault(); 218 | 219 | if (certificate == null) 220 | { 221 | throw new ArgumentException($"Certificate with thumbprint {thumbprint} not found."); 222 | } 223 | return true; 224 | } 225 | finally 226 | { 227 | store?.Close(); 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Model/Batch.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Model; 2 | 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Globalization; 6 | using System.IO; 7 | using System.IO.Compression; 8 | using System.Linq; 9 | using System.Net.Http; 10 | using System.Net.Http.Headers; 11 | using System.Text; 12 | using Newtonsoft.Json; 13 | 14 | [Serializable] 15 | public class Batch 16 | { 17 | public string Id { get; set; } 18 | 19 | [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] 20 | public BatchResponse Response { get; set; } 21 | 22 | public int RunCount { get; set; } 23 | } 24 | 25 | [Serializable] 26 | public class Batch : Batch 27 | { 28 | public ChangeSet ChangeSet { get; set; } 29 | 30 | public Batch() { } 31 | 32 | public Batch(ChangeSet changeSet) 33 | { 34 | Id = Guid.NewGuid().ToString(); 35 | ChangeSet = changeSet; 36 | } 37 | 38 | public Batch(IEnumerable> operations) 39 | { 40 | Id = Guid.NewGuid().ToString(); 41 | ChangeSet = new ChangeSet { Id = Guid.NewGuid().ToString(), Operations = operations }; 42 | } 43 | 44 | public Batch(IEnumerable values, string method, string Uri) 45 | { 46 | Id = Guid.NewGuid().ToString(); 47 | ChangeSet = new ChangeSet 48 | { 49 | Id = Guid.NewGuid().ToString(), 50 | Operations = values.Select(v => new Operation() { Method = method, Uri = Uri, Value = v }).ToList() 51 | }; 52 | } 53 | 54 | public override string ToString() 55 | { 56 | var sb = new StringBuilder(); 57 | 58 | if (string.IsNullOrEmpty(Id)) 59 | { 60 | Id = Guid.NewGuid().ToString(); 61 | } 62 | if (string.IsNullOrEmpty(ChangeSet.Id)) 63 | { 64 | ChangeSet.Id = Guid.NewGuid().ToString(); 65 | } 66 | 67 | // Batch Header 68 | sb.Append("--batch_").AppendLine(Id); 69 | sb.Append("Content-Type: multipart/mixed;boundary=changeset_").AppendLine(ChangeSet.Id).AppendLine(); 70 | 71 | // Change Set 72 | sb.Append(ChangeSet.ToString()); 73 | 74 | // Batch Terminator 75 | sb.AppendLine().Append("--batch_").Append(Id).AppendLine("--"); 76 | 77 | return sb.ToString(); 78 | } 79 | 80 | public MemoryStream ToStringCompressedStream(CompressionLevel level) 81 | { 82 | var content = ToString(); 83 | var bytes = Encoding.UTF8.GetBytes(content); 84 | var compressed = new MemoryStream(); 85 | using (var compressor = new GZipStream(compressed, level, true)) 86 | { 87 | compressor.Write(bytes, 0, bytes.Length); 88 | } 89 | compressed.Seek(0, SeekOrigin.Begin); 90 | return compressed; 91 | } 92 | 93 | public MemoryStream ToCompressedJsonStream(CompressionLevel level) 94 | { 95 | var content = JsonConvert.SerializeObject(this, Formatting.None); 96 | var bytes = Encoding.UTF8.GetBytes(content); 97 | var compressed = new MemoryStream(); 98 | using (var compressor = new GZipStream(compressed, level, true)) 99 | { 100 | compressor.Write(bytes, 0, bytes.Length); 101 | } 102 | compressed.Seek(0, SeekOrigin.Begin); 103 | return compressed; 104 | } 105 | 106 | public string ToStringCompressedBase64(CompressionLevel level) 107 | { 108 | var bytes = ToStringCompressedStream(level).ToArray(); 109 | return Convert.ToBase64String(bytes); 110 | } 111 | 112 | public string ToJsonCompressedBase64(CompressionLevel level) => Convert.ToBase64String(ToJsonCompressed(level)); 113 | 114 | public byte[] ToJsonCompressed(CompressionLevel level) 115 | { 116 | using var stream = ToCompressedJsonStream(level); 117 | return stream.ToArray(); 118 | } 119 | 120 | public IEnumerable ToJsonCompressed( 121 | CompressionLevel compressionLevel, int maxBinarySize, bool useFirstOperationIdAsBatchId) 122 | => GenerateBatches(ChangeSet.Operations, compressionLevel, maxBinarySize, useFirstOperationIdAsBatchId); 123 | 124 | public IEnumerable ToJsonCompressed( 125 | CompressionLevel compressionLevel, int maxBinarySize) 126 | => GenerateBatches(ChangeSet.Operations, compressionLevel, maxBinarySize, useFirstOperationIdAsBatchId: false); 127 | 128 | private static IEnumerable GenerateBatches( 129 | IEnumerable> operations, 130 | CompressionLevel compressionLevel, 131 | int maxBinarySize) 132 | { 133 | var batch = new Batch(operations); 134 | var compressedBatch = batch.ToJsonCompressed(compressionLevel); 135 | if (operations.Count() == 1 && compressedBatch.Length > maxBinarySize) 136 | { 137 | var errorMessage = string.Format( 138 | CultureInfo.InvariantCulture, 139 | "Even with one operation, the size of the batch is {0} which is larger than maximum allowed ({1}).", 140 | compressedBatch.Length, maxBinarySize); 141 | throw new InvalidOperationException(errorMessage); 142 | } 143 | else if (compressedBatch.Length > maxBinarySize) 144 | { 145 | var firstCount = operations.Count() / 2; 146 | var v1 = GenerateBatches(operations.Take(firstCount).ToList(), compressionLevel, maxBinarySize); 147 | var v2 = GenerateBatches(operations.Skip(firstCount).ToList(), compressionLevel, maxBinarySize); 148 | foreach (var item in v1) 149 | { 150 | yield return item; 151 | } 152 | foreach (var item in v2) 153 | { 154 | yield return item; 155 | } 156 | } 157 | else 158 | { 159 | yield return compressedBatch; 160 | } 161 | 162 | yield break; 163 | } 164 | 165 | private static IEnumerable GenerateBatches( 166 | IEnumerable> operations, 167 | CompressionLevel compressionLevel, 168 | int maxBinarySize, 169 | bool useFirstOperationIdAsBatchId) 170 | { 171 | var batch = new Batch(operations); 172 | if (useFirstOperationIdAsBatchId) 173 | { 174 | batch.Id = operations.First().ContentId; 175 | } 176 | var compressedBatch = batch.ToJsonCompressed(compressionLevel); 177 | if (operations.Count() == 1 && compressedBatch.Length > maxBinarySize) 178 | { 179 | var errorMessage = string.Format( 180 | CultureInfo.InvariantCulture, 181 | "Even with one operation, the size of the batch is {0} which is larger than maximum allowed ({1}).", 182 | compressedBatch.Length, maxBinarySize); 183 | throw new InvalidOperationException(errorMessage); 184 | } 185 | else if (compressedBatch.Length > maxBinarySize) 186 | { 187 | var firstCount = operations.Count() / 2; 188 | var v1 = GenerateBatches(operations.Take(firstCount).ToList(), compressionLevel, maxBinarySize); 189 | var v2 = GenerateBatches(operations.Skip(firstCount).ToList(), compressionLevel, maxBinarySize); 190 | foreach (var item in v1) 191 | { 192 | yield return item; 193 | } 194 | foreach (var item in v2) 195 | { 196 | yield return item; 197 | } 198 | } 199 | else 200 | { 201 | yield return compressedBatch; 202 | } 203 | 204 | yield break; 205 | } 206 | 207 | public HttpRequestMessage ToHttpRequest(string requestUri) 208 | { 209 | var request = new HttpRequestMessage(HttpMethod.Post, requestUri) 210 | { 211 | Content = new StringContent(ToString()) 212 | }; 213 | AddRequiredHeadersToRequest(request); 214 | return request; 215 | } 216 | 217 | public HttpRequestMessage ToCompressedHttpRequest(string requestUri, CompressionLevel level) 218 | { 219 | var request = new HttpRequestMessage(HttpMethod.Post, requestUri) 220 | { 221 | Content = new StreamContent(ToStringCompressedStream(level)) 222 | }; 223 | AddRequiredHeadersToRequest(request); 224 | return request; 225 | } 226 | 227 | internal static Batch Parse(string compressedBase64String) 228 | { 229 | var bytes = Convert.FromBase64String(compressedBase64String); 230 | using (var compressedStream = new MemoryStream(bytes)) 231 | { 232 | using var decompressed = new MemoryStream(); 233 | using (var decompressor = new GZipStream(compressedStream, CompressionMode.Decompress)) 234 | { 235 | decompressor.CopyTo(decompressed); 236 | } 237 | decompressed.Seek(0, SeekOrigin.Begin); 238 | bytes = decompressed.ToArray(); 239 | } 240 | var json = Encoding.UTF8.GetString(bytes); 241 | return JsonConvert.DeserializeObject>(json); 242 | } 243 | 244 | #region Private Methods 245 | 246 | private void AddRequiredHeadersToRequest(HttpRequestMessage request) 247 | { 248 | request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/mixed;boundary=batch_" + Id); 249 | request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json")); 250 | request.Content.Headers.Add("OData-MaxVersion", "4.0"); 251 | request.Content.Headers.Add("OData-Version", "4.0"); 252 | } 253 | 254 | #endregion 255 | } 256 | -------------------------------------------------------------------------------- /tests/PSDataverse.Tests/samples/DataverseEntity.sbn: -------------------------------------------------------------------------------- 1 | {{func contains(attribute) 2 | ret ["Virtual"] | !array.contains attribute.AttributeType 3 | end 4 | func isLocal(picklist) 5 | ret !picklist.IsGlobal 6 | end}} 7 | {{ classname = EntityDefinition.DisplayName.LocalizedLabels[0].Label | tokenize }} 8 | using Microsoft.Xrm.Sdk; 9 | using System.Collections.Generic; 10 | using System; 11 | 12 | public class {{ classname }} : Entity 13 | { 14 | #region Local OptionSets 15 | {{~ unique_enums = [] ~}} 16 | {{~ for attribute in StatusAttributes | array.filter @isLocal~}} 17 | {{ name = attribute.OptionSet.DisplayName.LocalizedLabels[0]?.Label ?? attribute.OptionSet.Name | tokenize}} 18 | {{~ if !array.contains unique_enums name ~}} 19 | {{~ unique_enums = unique_enums | array.add name ~}} 20 | public enum {{ name }} 21 | { 22 | {{~ for option in attribute.OptionSet.Options ~}} 23 | {{ option.Label.LocalizedLabels[0].Label | tokenize }} = {{ option.Value }}, 24 | {{~ end ~}} 25 | } 26 | {{~ end ~}} 27 | {{~ end ~}} 28 | 29 | {{~ for attribute in PickListAttributes | array.filter @isLocal~}} 30 | {{~ name = attribute.OptionSet.DisplayName.LocalizedLabels[0]?.Label ?? attribute.OptionSet.Name | tokenize ~}} 31 | {{~ if !array.contains unique_enums name ~}} 32 | {{~ unique_enums = unique_enums | array.add name ~}} 33 | public enum {{ name }} 34 | { 35 | {{~ for option in attribute.OptionSet.Options ~}} 36 | {{ option.Label.LocalizedLabels[0].Label | tokenize }} = {{ option.Value }}, 37 | {{~ end ~}} 38 | } 39 | {{~ end ~}} 40 | {{~ end ~}} 41 | #endregion 42 | 43 | public {{classname}}() : base("{{ EntityDefinition.LogicalName }}") { } 44 | 45 | public {{classname}}(Entity entity) : base(entity.LogicalName) 46 | { 47 | Id = entity.Id; 48 | Attributes.AddRange(entity.Attributes); 49 | } 50 | 51 | public {{classname}}(EntityReference entityReference) : base(entityReference.LogicalName) 52 | { 53 | Id = entityReference.Id; 54 | } 55 | 56 | public {{classname}} (string logicalName) : base(logicalName) 57 | { 58 | } 59 | 60 | public {{classname}} (string logicalName, params KeyValuePair[] attributes) : base(logicalName) 61 | { 62 | Attributes.AddRange(attributes); 63 | } 64 | 65 | public {{classname}} (string logicalName, Guid id) : base(logicalName) 66 | { 67 | Id = id; 68 | } 69 | 70 | public {{classname}} (string logicalName, Guid id, params KeyValuePair[] attributes) : base(logicalName) 71 | { 72 | Id = id; 73 | Attributes.AddRange(attributes); 74 | } 75 | 76 | public Guid Id 77 | { 78 | get => GetAttributeValue("{{ EntityDefinition.PrimaryIdAttribute }}id"); 79 | set => this["{{ EntityDefinition.PrimaryIdAttribute }}id"] = value; 80 | } 81 | 82 | {{~ for attribute in StatusAttributes ~}} 83 | {{~ propertyName = attribute.DisplayName.LocalizedLabels[0]?.Label ?? attribute.SchemaName | tokenize ~}} 84 | {{~ enumName = attribute.OptionSet.DisplayName.LocalizedLabels[0]?.Label ?? attribute.OptionSet.Name | tokenize ~}} 85 | public virtual {{enumName}}? {{propertyName}} 86 | { 87 | get => ({{attribute.OptionSet.Name}}?)GetAttributeValue("{{ attribute.LogicalName }}")?.Value; 88 | {{~ if attribute.IsValidForUpdate == "True" ~}} 89 | set => this["{{ attribute.LogicalName }}"] = value.HasValue ? new OptionSetValue((int)value) : null; 90 | {{~ end ~}} 91 | } 92 | {{~ end ~}} 93 | 94 | {{~ for attribute in PicklistAttributes ~}} 95 | {{~ enumName = attribute.OptionSet.DisplayName.LocalizedLabels[0]?.Label ?? attribute.OptionSet.Name | tokenize ~}} 96 | {{~ propertyName = attribute.DisplayName.LocalizedLabels[0]?.Label ?? attribute.SchemaName | tokenize ~}} 97 | public virtual {{enumName}}? {{propertyName}} 98 | { 99 | get => ({{attribute.OptionSet.Name}}?)GetAttributeValue("{{ attribute.LogicalName }}")?.Value; 100 | {{~ if attribute.IsValidForUpdate == "True" ~}} 101 | set => this["{{ attribute.LogicalName }}"] = value.HasValue ? new OptionSetValue((int)value) : null; 102 | {{~ end ~}} 103 | } 104 | {{~ end ~}} 105 | 106 | {{~ for attribute in MultiSelectPicklistAttributes ~}} 107 | {{~ enumName = attribute.OptionSet.DisplayName.LocalizedLabels[0]?.Label ?? attribute.OptionSet.Name | tokenize ~}} 108 | {{~ propertyName = attribute.DisplayName.LocalizedLabels[0]?.Label ?? attribute.SchemaName | tokenize ~}} 109 | public virtual {{enumName}}? {{propertyName}} 110 | { 111 | get 112 | { 113 | var optionSets = GetAttributeValue("{{ attribute.LogicalName }}"); 114 | if (optionSets == null) 115 | { 116 | return Enumerable.Empty<{{attribute.OptionSet.Name}}?>(); 117 | } 118 | return optionsets.Select(opt => ({{attribute.OptionSet.Name}}?)(opt.Value)).ToList(); 119 | } 120 | {{~ if attribute.IsValidForUpdate == "True" ~}} 121 | set => this["{{ attribute.LogicalName }}"] = value.HasValue ? new OptionSetValue((int)value) : null; 122 | {{~ end ~}} 123 | } 124 | {{~ end ~}} 125 | 126 | {{~ for attribute in MoneyAttributes ~}} 127 | {{~ propertyName = attribute.DisplayName.LocalizedLabels[0]?.Label ?? attribute.SchemaName | tokenize ~}} 128 | public virtual decimal? {{propertyName}} 129 | { 130 | get => (decimal?)GetAttributeValue("{{ attribute.LogicalName }}")?.Value; 131 | {{~ if attribute.IsValidForUpdate == "True" ~}} 132 | set => this["{{ attribute.LogicalName }}"] = value.HasValue ? new Money(value) : null; 133 | {{~ end ~}} 134 | } 135 | {{~ end ~}} 136 | 137 | {{~ for attribute in DecimalAttributes ~}} 138 | {{~ propertyName = attribute.DisplayName.LocalizedLabels[0]?.Label ?? attribute.SchemaName | tokenize ~}} 139 | public virtual decimal? {{propertyName}} 140 | { 141 | get => GetAttributeValue("{{ attribute.LogicalName }}")?.Value; 142 | {{~ if attribute.IsValidForUpdate == "True" ~}} 143 | set => this["{{ attribute.LogicalName }}"] = value.HasValue ? value : null; 144 | {{~ end ~}} 145 | } 146 | {{~ end ~}} 147 | 148 | {{~ for attribute in BigIntAttributes ~}} 149 | {{~ propertyName = attribute.DisplayName.LocalizedLabels[0]?.Label ?? attribute.SchemaName | tokenize ~}} 150 | public virtual BigInteger? {{propertyName}} 151 | { 152 | get => GetAttributeValue("{{ attribute.LogicalName }}")?.Value; 153 | {{~ if attribute.IsValidForUpdate == "True" ~}} 154 | set => this["{{ attribute.LogicalName }}"] = value.HasValue ? value : null; 155 | {{~ end ~}} 156 | } 157 | {{~ end ~}} 158 | 159 | {{~ for attribute in IntegerAttributes ~}} 160 | {{~ propertyName = attribute.DisplayName.LocalizedLabels[0]?.Label ?? attribute.SchemaName | tokenize ~}} 161 | public virtual int? {{propertyName}} 162 | { 163 | get => GetAttributeValue("{{ attribute.LogicalName }}")?.Value; 164 | {{~ if attribute.IsValidForUpdate == "True" ~}} 165 | set => this["{{ attribute.LogicalName }}"] = value.HasValue ? value : null; 166 | {{~ end ~}} 167 | } 168 | {{~ end ~}} 169 | 170 | {{~ for attribute in DoubleAttributes ~}} 171 | {{~ propertyName = attribute.DisplayName.LocalizedLabels[0]?.Label ?? attribute.SchemaName | tokenize ~}} 172 | public virtual double? {{propertyName}} 173 | { 174 | get => GetAttributeValue("{{ attribute.LogicalName }}")?.Value; 175 | {{~ if attribute.IsValidForUpdate == "True" ~}} 176 | set => this["{{ attribute.LogicalName }}"] = value.HasValue ? value : null; 177 | {{~ end ~}} 178 | } 179 | {{~ end ~}} 180 | 181 | {{~ for attribute in DateTimeAttributes ~}} 182 | {{~ propertyName = attribute.DisplayName.LocalizedLabels[0]?.Label ?? attribute.SchemaName | tokenize ~}} 183 | public virtual DateTime? {{propertyName}} 184 | { 185 | get => GetAttributeValue("{{ attribute.LogicalName }}")?.Value; 186 | {{~ if attribute.IsValidForUpdate == "True" ~}} 187 | set => this["{{ attribute.LogicalName }}"] = value.HasValue ? value : null; 188 | {{~ end ~}} 189 | } 190 | {{~ end ~}} 191 | 192 | {{~ for attribute in BooleanAttributes ~}} 193 | {{~ propertyName = attribute.DisplayName.LocalizedLabels[0]?.Label ?? attribute.SchemaName | tokenize ~}} 194 | public virtual bool? {{propertyName}} 195 | { 196 | get => GetAttributeValue("{{ attribute.LogicalName }}")?.Value; 197 | {{~ if attribute.IsValidForUpdate == "True" ~}} 198 | set => this["{{ attribute.LogicalName }}"] = value.HasValue ? value : null; 199 | {{~ end ~}} 200 | } 201 | {{~ end ~}} 202 | 203 | {{~ for attribute in UniqueIdentifierAttributes ~}} 204 | {{~ propertyName = attribute.DisplayName.LocalizedLabels[0]?.Label ?? attribute.SchemaName | tokenize ~}} 205 | public virtual Guid? {{propertyName}} 206 | { 207 | get => GetAttributeValue("{{ attribute.LogicalName }}")?.Value; 208 | {{~ if attribute.IsValidForUpdate == "True" ~}} 209 | set => this["{{ attribute.LogicalName }}"] = value.HasValue ? value : null; 210 | {{~ end ~}} 211 | } 212 | {{~ end ~}} 213 | 214 | {{~ for attribute in StringAttributes ~}} 215 | {{~ propertyName = attribute.DisplayName.LocalizedLabels[0]?.Label ?? attribute.SchemaName | tokenize ~}} 216 | public virtual string {{propertyName}} 217 | { 218 | get => GetAttributeValue("{{ attribute.LogicalName }}")?.Value; 219 | {{~ if attribute.IsValidForUpdate == "True" ~}} 220 | set => this["{{ attribute.LogicalName }}"] = value; 221 | {{~ end ~}} 222 | } 223 | {{~ end ~}} 224 | 225 | {{~ for attribute in MemoAttributes ~}} 226 | {{~ propertyName = attribute.DisplayName.LocalizedLabels[0]?.Label ?? attribute.SchemaName | tokenize ~}} 227 | public virtual string {{propertyName}} 228 | { 229 | get => GetAttributeValue("{{ attribute.LogicalName }}")?.Value; 230 | {{~ if attribute.IsValidForUpdate == "True" ~}} 231 | set => this["{{ attribute.LogicalName }}"] = value; 232 | {{~ end ~}} 233 | } 234 | {{~ end ~}} 235 | } 236 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Execute/BatchProcessor.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse.Dataverse.Execute; 2 | 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Globalization; 6 | using System.Linq; 7 | using System.Net; 8 | using System.Net.Http; 9 | using System.Net.Http.Headers; 10 | using System.Net.Mime; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | using Microsoft.Extensions.Logging; 14 | using Newtonsoft.Json; 15 | using Newtonsoft.Json.Linq; 16 | using Polly; 17 | using Polly.Registry; 18 | using PSDataverse.Dataverse.Model; 19 | 20 | public class BatchProcessor : Processor, IBatchProcessor 21 | { 22 | private readonly ILogger log; 23 | private readonly HttpClient httpClient; 24 | private readonly IAsyncPolicy retry; 25 | private readonly bool canThrowOperationException; 26 | public string AuthenticationToken 27 | { 28 | set => httpClient.DefaultRequestHeaders.Authorization = 29 | string.IsNullOrEmpty(value) ? null : new AuthenticationHeaderValue("Bearer", value); 30 | } 31 | public BatchProcessor( 32 | ILogger log, 33 | IHttpClientFactory httpClientFactory, 34 | IReadOnlyPolicyRegistry policyRegistry, 35 | string authenticationToken) : this(log, httpClientFactory, policyRegistry) 36 | { 37 | canThrowOperationException = false; 38 | AuthenticationToken = authenticationToken; 39 | } 40 | 41 | public BatchProcessor( 42 | ILogger log, 43 | IHttpClientFactory httpClientFactory, 44 | IReadOnlyPolicyRegistry policyRegistry) 45 | { 46 | this.log = log; 47 | httpClient = httpClientFactory.CreateClient("Dataverse"); 48 | retry = policyRegistry.Get>(Globals.PolicyNameHttp); 49 | } 50 | 51 | // public async IAsyncEnumerable ProcessAsync(Batch batch) 52 | public async IAsyncEnumerable ProcessAsync(Batch batch) 53 | { 54 | //foreach (var operation in batch.ChangeSet.Operations) 55 | //{ 56 | // operation.Uri = new Uri(ServiceUrl, operation.Uri).ToString(); 57 | // if (operation.Uri.EndsWith("$ref", StringComparison.OrdinalIgnoreCase)) 58 | // { 59 | // if (operation.Value["@odata.id"] != null) 60 | // { 61 | // operation.Value["@odata.id"] = new Uri(ServiceUrl, operation.Value["@odata.id"].ToString()); 62 | // } 63 | // } 64 | //} 65 | //TODO: Parse all messages in the response and yield-return them separately. 66 | yield return await ExecuteBatchAsync(batch); 67 | } 68 | 69 | public Task ExecuteBatchAsync(Batch batch) => ExecuteBatchAsync(batch, CancellationToken.None); 70 | public Task ExecuteBatchAsync(Batch batch) => ExecuteBatchAsync(batch, CancellationToken.None); 71 | public async Task ExecuteBatchAsync(Batch batch, CancellationToken cancellationToken) 72 | { 73 | // Make the request 74 | var response = await SendBatchAsync(batch, cancellationToken); 75 | log.LogDebug($"Dynamics 365: {(int)response.StatusCode} {response.ReasonPhrase}"); 76 | 77 | // Extract the response content 78 | string responseContent = null; 79 | if (response.Content != null) 80 | { 81 | responseContent = await response.Content.ReadAsStringAsync(cancellationToken); 82 | response.Content.Dispose(); 83 | } 84 | 85 | // Catch throtelling exceptions 86 | WebApiFault details = null; 87 | if (!response.IsSuccessStatusCode && response.Content.Headers.ContentType?.MediaType == MediaTypeNames.Application.Json) 88 | { 89 | details = JsonConvert.DeserializeObject(responseContent); 90 | } 91 | if (response.StatusCode == HttpStatusCode.TooManyRequests) 92 | { 93 | details.RetryAfter = response.Headers.RetryAfter?.Delta; 94 | throw new ThrottlingExceededException(details); 95 | } 96 | if (response.Headers.RetryAfter != null) 97 | { 98 | details = new WebApiFault 99 | { 100 | Message = $"Response status code does not indicate success: " + 101 | $"{response.StatusCode} ({response.ReasonPhrase}) content: {responseContent}.", 102 | ErrorCode = (int)response.StatusCode, 103 | RetryAfter = response.Headers.RetryAfter.Delta 104 | }; 105 | throw new ThrottlingExceededException(details); 106 | } 107 | 108 | if (response.Content.Headers.ContentType != null && !string.Equals("multipart/mixed", response.Content.Headers.ContentType.MediaType, StringComparison.OrdinalIgnoreCase)) 109 | { 110 | throw new ParseException($"Unsupported response media type received from Dataverse. Expected: multipart/mixed, Actual: " + response.Content.Headers.ContentType.MediaType); 111 | } 112 | 113 | if (!response.IsSuccessStatusCode && response.Content.Headers.ContentLength == 0) 114 | { 115 | throw new BatchException($"{(int)response.StatusCode} {response.ReasonPhrase}") 116 | { 117 | Batch = batch 118 | }; 119 | } 120 | 121 | BatchResponse batchResponse = null; 122 | try 123 | { 124 | // Try to parse the response content like a batch response 125 | batchResponse = BatchResponse.Parse(responseContent); 126 | } 127 | catch (Exception) 128 | { 129 | log.LogWarning("It is not possible to parse the CRM response!\r\n" + responseContent); 130 | } 131 | 132 | if (batchResponse.IsSuccessful) 133 | { 134 | return batchResponse; 135 | } 136 | 137 | log.LogDebug("Dynamics 365 response: " + responseContent); 138 | 139 | var failedOperationResponse = batchResponse.Operations.First(); 140 | var failedOperation = batch.ChangeSet.Operations.FirstOrDefault( 141 | o => o.ContentId == failedOperationResponse.ContentId); 142 | log.LogWarning($"Failed operation: {failedOperation}."); 143 | failedOperation.RunCount++; 144 | 145 | if (canThrowOperationException) 146 | { 147 | throw CreateOperationException(batch.Id, failedOperation, failedOperationResponse); 148 | } 149 | else 150 | { 151 | return batchResponse; 152 | } 153 | } 154 | 155 | public async Task ExecuteBatchAsync(Batch batch, CancellationToken cancellationToken) 156 | { 157 | // Make the request 158 | var response = await SendBatchAsync(batch, cancellationToken); 159 | log.LogDebug($"Dynamics 365: {(int)response.StatusCode} {response.ReasonPhrase}"); 160 | 161 | // Extract the response content 162 | string responseContent = null; 163 | if (response.Content != null) 164 | { 165 | responseContent = await response.Content.ReadAsStringAsync(cancellationToken); 166 | response.Content.Dispose(); 167 | } 168 | 169 | // Catch throtelling exceptions 170 | WebApiFault details = null; 171 | if (!response.IsSuccessStatusCode && response.Content.Headers.ContentType?.MediaType == MediaTypeNames.Application.Json) 172 | { 173 | details = JsonConvert.DeserializeObject(responseContent); 174 | } 175 | if (response.StatusCode == HttpStatusCode.TooManyRequests) 176 | { 177 | details.RetryAfter = response.Headers.RetryAfter?.Delta; 178 | throw new ThrottlingExceededException(details); 179 | } 180 | if (response.Headers.RetryAfter != null) 181 | { 182 | details = new WebApiFault 183 | { 184 | Message = $"Response status code does not indicate success: " + 185 | $"{response.StatusCode} ({response.ReasonPhrase}) content: {responseContent}.", 186 | ErrorCode = (int)response.StatusCode, 187 | RetryAfter = response.Headers.RetryAfter.Delta 188 | }; 189 | throw new ThrottlingExceededException(details); 190 | } 191 | 192 | if (response.Content.Headers.ContentType != null && !string.Equals("multipart/mixed", response.Content.Headers.ContentType.MediaType, StringComparison.OrdinalIgnoreCase)) 193 | { 194 | throw new ParseException($"Unsupported response media type received from Dataverse. Expected: multipart/mixed, Actual: " + response.Content.Headers.ContentType.MediaType); 195 | } 196 | 197 | if (!response.IsSuccessStatusCode && response.Content.Headers.ContentLength == 0) 198 | { 199 | throw new BatchException($"{(int)response.StatusCode} {response.ReasonPhrase}") 200 | { 201 | Batch = batch 202 | }; 203 | } 204 | 205 | BatchResponse batchResponse = null; 206 | try 207 | { 208 | // Try to parse the response content like a batch response 209 | batchResponse = BatchResponse.Parse(responseContent); 210 | } 211 | catch (Exception) 212 | { 213 | log.LogWarning("It is not possible to parse the CRM response!\r\n" + responseContent); 214 | } 215 | 216 | if (batchResponse.IsSuccessful) 217 | { 218 | return batchResponse; 219 | } 220 | 221 | log.LogDebug("Dynamics 365 response: " + responseContent); 222 | 223 | var failedOperationResponse = batchResponse.Operations.First(); 224 | var failedOperation = batch.ChangeSet.Operations.FirstOrDefault( 225 | o => o.ContentId == failedOperationResponse.ContentId); 226 | log.LogWarning($"Failed operation: {failedOperation}."); 227 | failedOperation.RunCount++; 228 | 229 | if (canThrowOperationException) 230 | { 231 | throw CreateOperationException(batch.Id, failedOperation, failedOperationResponse); 232 | } 233 | else 234 | { 235 | return batchResponse; 236 | } 237 | } 238 | 239 | private Task SendBatchAsync(Batch batch) => SendBatchAsync(batch, CancellationToken.None); 240 | private Task SendBatchAsync(Batch batch) => SendBatchAsync(batch, CancellationToken.None); 241 | 242 | private async Task SendBatchAsync(Batch batch, CancellationToken cancellationToken) 243 | { 244 | HttpResponseMessage response = null; 245 | if (batch is null) 246 | { throw new ArgumentNullException(nameof(batch)); } 247 | if (batch.Id == null) 248 | { throw new ArgumentException("Batch.Id cannot be null."); } 249 | 250 | log.LogDebug($"Executing batch {batch.Id}..."); 251 | response = await retry.ExecuteAsync(() => httpClient.SendAsync(HttpMethod.Post, "$batch", batch, cancellationToken)); 252 | if (response.IsSuccessStatusCode) 253 | { 254 | log.LogDebug($"Batch {batch.Id} succeeded."); 255 | return response; 256 | } 257 | if (response != null) 258 | { 259 | return response; 260 | } 261 | throw new HttpRequestException( 262 | string.Format( 263 | CultureInfo.InvariantCulture, 264 | "Response status code does not indicate success: {0} ({1}) and the response contains no content.", 265 | response.StatusCode, 266 | response.ReasonPhrase)); 267 | } 268 | 269 | private async Task SendBatchAsync(Batch batch, CancellationToken cancellationToken) 270 | { 271 | HttpResponseMessage response = null; 272 | if (batch is null) 273 | { throw new ArgumentNullException(nameof(batch)); } 274 | if (batch.Id == null) 275 | { throw new ArgumentException("Batch.Id cannot be null."); } 276 | 277 | log.LogDebug($"Executing batch {batch.Id}..."); 278 | response = await retry.ExecuteAsync(() => httpClient.SendAsync(HttpMethod.Post, "$batch", batch, cancellationToken)); 279 | if (response.IsSuccessStatusCode) 280 | { 281 | log.LogDebug($"Batch {batch.Id} succeeded."); 282 | return response; 283 | } 284 | if (response != null) 285 | { 286 | return response; 287 | } 288 | throw new HttpRequestException( 289 | string.Format( 290 | CultureInfo.InvariantCulture, 291 | "Response status code does not indicate success: {0} ({1}) and the response contains no content.", 292 | response.StatusCode, 293 | response.ReasonPhrase)); 294 | } 295 | 296 | private OperationException CreateOperationException( 297 | string batchId, 298 | Operation operation, 299 | OperationResponse response) 300 | { 301 | var entityName = ExtractEntityName(operation); 302 | var errorMessage = response.Error.Message; 303 | //if (operationExceptions.TryGetValue(entityName, out OperationException exception)) 304 | //{ 305 | // exception.Message = errorMessage; 306 | // exception.BatchId = batchId; 307 | // exception.Operation = operation; 308 | // exception.Error = response.Error; 309 | // return exception; 310 | //} 311 | return new OperationException(errorMessage) 312 | { 313 | BatchId = batchId, 314 | Operation = operation, 315 | Error = response.Error, 316 | EntityName = entityName, 317 | }; 318 | } 319 | 320 | private OperationException CreateOperationException( 321 | string batchId, 322 | Operation operation, 323 | OperationResponse response) 324 | { 325 | var entityName = ExtractEntityName(operation); 326 | var errorMessage = response.Error.Message; 327 | //if (operationExceptions.TryGetValue(entityName, out OperationException exception)) 328 | //{ 329 | // exception.Message = errorMessage; 330 | // exception.BatchId = batchId; 331 | // exception.Operation = operation; 332 | // exception.Error = response.Error; 333 | // return exception; 334 | //} 335 | return new OperationException(errorMessage) 336 | { 337 | BatchId = batchId, 338 | Operation = operation, 339 | Error = response.Error, 340 | EntityName = entityName, 341 | }; 342 | } 343 | 344 | private static void ThrowGeneralException(HttpResponseMessage response) 345 | { 346 | string responseContent = null; 347 | if (response.Content != null) 348 | { 349 | responseContent = response.Content.ReadAsStringAsync().Result; 350 | response.Content.Dispose(); 351 | } 352 | 353 | WebApiFault details; 354 | if (response.StatusCode == HttpStatusCode.TooManyRequests) 355 | { 356 | details = JsonConvert.DeserializeObject(responseContent); 357 | details.RetryAfter = response.Headers.RetryAfter?.Delta; 358 | throw new ThrottlingExceededException(details); 359 | } 360 | if (response.Headers.RetryAfter != null) 361 | { 362 | details = new WebApiFault 363 | { 364 | Message = $"Response status code does not indicate success: " + 365 | $"{response.StatusCode} ({response.ReasonPhrase}) content: {responseContent}.", 366 | ErrorCode = (int)response.StatusCode, 367 | RetryAfter = response.Headers.RetryAfter.Delta 368 | }; 369 | throw new ThrottlingExceededException(details); 370 | } 371 | 372 | throw new HttpRequestException( 373 | $"Response status code does not indicate success: " + 374 | $"{(int)response.StatusCode} ({response.ReasonPhrase}) content: {responseContent}."); 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /src/PSDataverse/Dataverse/Commands/SendDataverseOperationCmdlet.cs: -------------------------------------------------------------------------------- 1 | namespace PSDataverse; 2 | 3 | using System; 4 | using System.Collections; 5 | using System.Collections.Concurrent; 6 | using System.Collections.Generic; 7 | using System.Diagnostics; 8 | using System.Linq; 9 | using System.Management.Automation; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | using Microsoft.Extensions.DependencyInjection; 13 | using Microsoft.PowerShell.Commands; 14 | using Newtonsoft.Json; 15 | using Newtonsoft.Json.Linq; 16 | using PSDataverse.Auth; 17 | using PSDataverse.Dataverse; 18 | using PSDataverse.Dataverse.Execute; 19 | using PSDataverse.Dataverse.Model; 20 | using PSDataverse.Extensions; 21 | 22 | [Cmdlet(VerbsCommunications.Send, "DataverseOperation", DefaultParameterSetName = "Object")] 23 | public class SendDataverseOperationCmdlet : DataverseCmdlet, IOperationReporter 24 | { 25 | [Parameter(Position = 0, Mandatory = true, ParameterSetName = "Operation", ValueFromPipeline = true)] 26 | public Operation InputOperation { get; set; } 27 | 28 | //[Parameter(Position = 0, Mandatory = true, ParameterSetName = "Json", ValueFromPipeline = true)] 29 | //public string InputJson { get; set; } 30 | 31 | [Parameter(Position = 0, Mandatory = true, ParameterSetName = "Object", ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] 32 | public PSObject InputObject { get; set; } 33 | 34 | [Parameter(Position = 1, Mandatory = false)] 35 | [Alias("BatchCapacity")] 36 | [ValidateRange(0, 1000)] 37 | public int BatchSize { get; set; } = 0; 38 | 39 | [Parameter(Position = 2, Mandatory = false)] 40 | [Alias("ThrottleLimit")] 41 | [ValidateRange(0, int.MaxValue)] 42 | public int MaxDop { get; set; } = 0; 43 | 44 | [Parameter(Position = 3, Mandatory = false)] 45 | [ValidateRange(0, 50)] 46 | public int Retry { get; set; } 47 | 48 | [Parameter(Position = 4, Mandatory = false)] 49 | public SwitchParameter OutputTable { get; set; } 50 | 51 | [Parameter(Position = 5, Mandatory = false)] 52 | public SwitchParameter AutoPaginate { get; set; } 53 | 54 | private bool IsOnPremise; 55 | private bool isValidationFailed; 56 | private string accessToken; 57 | private AuthenticationParameters dataverseCnnStr; 58 | private AuthenticationService authenticationService; 59 | private DateTimeOffset? authExpiresOn; 60 | private OperationProcessor operationProcessor; 61 | private OperationHandler operationHandler; 62 | private BatchProcessor batchProcessor; 63 | private int operationCounter; 64 | private int batchCounter; 65 | private ConcurrentBag> operations; 66 | private List>> tasks; 67 | private Stopwatch stopwatch; 68 | private SemaphoreSlim taskThrottler; 69 | 70 | private static readonly string[] ValidMethodsWithoutPayload = ["GET", "DELETE"]; 71 | 72 | protected override void BeginProcessing() 73 | { 74 | base.BeginProcessing(); 75 | 76 | stopwatch = Stopwatch.StartNew(); 77 | 78 | var serviceProvider = (IServiceProvider)GetVariableValue(Globals.VariableNameServiceProvider); 79 | operationProcessor = serviceProvider.GetService(); 80 | batchProcessor = serviceProvider.GetService(); 81 | authenticationService = serviceProvider.GetService(); 82 | operationHandler = new(operationProcessor, this); 83 | if (!VerifyConnection()) 84 | { 85 | isValidationFailed = true; 86 | return; 87 | } 88 | 89 | if (BatchSize > 0) 90 | { 91 | operations = [.. new List>(BatchSize)]; 92 | tasks = []; 93 | taskThrottler = new SemaphoreSlim(MaxDop <= 0 ? 20 : MaxDop); 94 | } 95 | operationCounter = 0; 96 | } 97 | 98 | protected override void ProcessRecord() 99 | { 100 | base.ProcessRecord(); 101 | 102 | if (isValidationFailed || !VerifyConnection()) 103 | { 104 | return; 105 | } 106 | 107 | if (!TryGetInputOperation(out var op)) 108 | { 109 | operationHandler.ReportMissingOperationError(); 110 | return; 111 | } 112 | 113 | OperationHandler.AssignDefaultHttpMethod(op); // This remains static, so called directly on the class 114 | 115 | ValidateOperation(op); 116 | 117 | _ = Interlocked.Increment(ref operationCounter); 118 | 119 | if (BatchSize <= 0) 120 | { 121 | operationHandler.ExecuteSingleOperation(op, accessToken, AutoPaginate.IsPresent); 122 | return; 123 | } 124 | 125 | operations.Add(op); 126 | 127 | if (IsNewBatchNeeded()) 128 | { 129 | batchProcessor.AuthenticationToken = accessToken; 130 | MakeAndSendBatchThenOutput(waitForAll: false); 131 | } 132 | } 133 | 134 | protected override void EndProcessing() 135 | { 136 | if (tasks?.Count > 0 || (operations?.Any() ?? false)) 137 | { 138 | MakeAndSendBatchThenOutput(waitForAll: true); 139 | } 140 | if (BatchSize == 0) 141 | { 142 | stopwatch.Stop(); 143 | WriteInformation($"Send-Dataverse completed - Elapsed: {stopwatch.Elapsed}, Batches: {batchCounter}, Operations: {operationCounter}.", ["Dataverse"]); 144 | return; 145 | } 146 | stopwatch.Stop(); 147 | _ = taskThrottler.Release(); 148 | taskThrottler.Dispose(); 149 | WriteInformation($"Send-Dataverse completed - Elapsed: {stopwatch.Elapsed}, Batches: {batchCounter}, Operations: {operationCounter}.", ["Dataverse"]); 150 | 151 | base.EndProcessing(); 152 | } 153 | 154 | private bool TryGetInputOperation(out Operation operation) 155 | { 156 | if (InputOperation is not null) 157 | { 158 | operation = InputOperation; 159 | return true; 160 | } 161 | if (InputObject is not null) 162 | { 163 | if (InputObject.BaseObject is string str) 164 | { 165 | // If the given string is not in JSON format, assume it's a URL. 166 | if (!str.StartsWith('{')) 167 | { 168 | str = $"{{\"Uri\":\"{str}\"}}"; 169 | } 170 | var jobject = JObject.Parse(str); 171 | operation = new Operation 172 | { 173 | ContentId = jobject.TryGetValue("ContentId", out var contentId) ? contentId.ToString() : null, 174 | Method = jobject.TryGetValue("Method", out var method) ? method.ToString() : null, 175 | Uri = jobject.TryGetValue("Uri", out var uri) ? uri.ToString() : null, 176 | Headers = jobject.TryGetValue("Headers", out var headers) ? headers.ToObject>() : null, 177 | Value = jobject.TryGetValue("Value", out var value) ? value.ToString(Formatting.None, []) : null 178 | }; 179 | return true; 180 | } 181 | if (InputObject.BaseObject is IDictionary dictionary) 182 | { 183 | operation = new Operation 184 | { 185 | ContentId = InputObject.TryGetPropertyValue("ContentId"), 186 | Method = InputObject.TryGetPropertyValue("Method"), 187 | Uri = InputObject.TryGetPropertyValue("Uri"), 188 | Headers = dictionary.TryGetValue("Headers", out var headers) && headers != null ? 189 | (headers as IDictionary).Cast().ToDictionary(e => e.Key.ToString(), e => e.Value.ToString()) 190 | : null, 191 | Value = dictionary.TryGetValue("Value", out var value) && value != null ? ConvertToJson(value) : null 192 | }; 193 | return true; 194 | } 195 | } 196 | operation = null; 197 | return false; 198 | } 199 | 200 | private string ConvertToJson(object objectToProcess) 201 | { 202 | var context = new JsonObject.ConvertToJsonContext( 203 | maxDepth: 10, 204 | enumsAsStrings: false, 205 | compressOutput: true, 206 | stringEscapeHandling: StringEscapeHandling.Default, 207 | targetCmdlet: this, 208 | cancellationToken: CancellationToken); 209 | return JsonObject.ConvertToJson(objectToProcess, in context); 210 | } 211 | 212 | private static void ValidateOperation(Operation operation) 213 | { 214 | if (operation is null) 215 | { 216 | throw new InvalidOperationException("Operation parameter is not provided."); 217 | } 218 | if (!ValidMethodsWithoutPayload.Contains(operation.Method, StringComparer.OrdinalIgnoreCase) && !operation.HasValue) 219 | { 220 | throw new InvalidOperationException( 221 | $"Operation does not have a 'Value' but it has a {operation.Method} method. All operations with non-GET method should have a value."); 222 | } 223 | } 224 | 225 | private bool VerifyConnection() 226 | { 227 | IsOnPremise = (bool)GetVariableValue(Globals.VariableNameIsOnPremise); 228 | if (IsOnPremise) { return true; } 229 | accessToken = (string)GetVariableValue(Globals.VariableNameAccessToken); 230 | authExpiresOn = (DateTimeOffset?)GetVariableValue(Globals.VariableNameAccessTokenExpiresOn); 231 | dataverseCnnStr = (AuthenticationParameters)GetVariableValue(Globals.VariableNameConnectionString); 232 | if (string.IsNullOrEmpty(accessToken)) 233 | { 234 | var errMessage = "No active connection detect. Please first authenticate using Connect-Dataverse cmdlet."; 235 | WriteError(new ErrorRecord(new InvalidOperationException(errMessage), Globals.ErrorIdNotConnected, ErrorCategory.ConnectionError, null)); 236 | return false; 237 | } 238 | // if (authExpiresOn <= DateTimeOffset.Now && dataverseCnnStr == null) 239 | // { 240 | // var errMessage = "Active connection has expired. Please authenticate again using Connect-Dataverse cmdlet."; 241 | // WriteError(new ErrorRecord(new InvalidOperationException(errMessage), Globals.ErrorIdConnectionExpired, ErrorCategory.ConnectionError, null)); 242 | // return false; 243 | // } 244 | // if (dataverseCnnStr != null && authExpiresOn <= DateTimeOffset.Now) 245 | // { 246 | var authResult = authenticationService.AuthenticateAsync(dataverseCnnStr, OnMessageForUser, CancellationToken).ConfigureAwait(false).GetAwaiter().GetResult(); 247 | SessionState.PSVariable.Set(new PSVariable(Globals.VariableNameAccessToken, authResult.AccessToken, ScopedItemOptions.AllScope)); 248 | SessionState.PSVariable.Set(new PSVariable(Globals.VariableNameAccessTokenExpiresOn, authResult.ExpiresOn, ScopedItemOptions.AllScope)); 249 | // } 250 | return true; 251 | } 252 | 253 | private void OnMessageForUser(string message) => WriteInformation(message, ["dataverse"]); 254 | 255 | private bool IsNewBatchNeeded() => (BatchSize > 0 && operationCounter == 0) || operationCounter % BatchSize == 0; 256 | 257 | private void MakeAndSendBatchThenOutput(bool waitForAll) 258 | { 259 | if (operations?.Count > 0) 260 | { 261 | var batch = new Batch(operations); 262 | operations.Clear(); 263 | var task = SendBatchAsync(batch); 264 | tasks.Add(task); 265 | } 266 | if (waitForAll) 267 | { 268 | var all = Task.WhenAll(tasks); 269 | try 270 | { 271 | var responses = all.Result; 272 | foreach (var response in responses) 273 | { 274 | WriteOutput(response); 275 | } 276 | } 277 | catch (AggregateException ex) 278 | { 279 | foreach (var exception in ex.InnerExceptions) 280 | { 281 | WriteError(new ErrorRecord(exception, Globals.ErrorIdBatchFailure, ErrorCategory.WriteError, this)); 282 | } 283 | } 284 | tasks.Clear(); 285 | } 286 | else 287 | { 288 | while (tasks.Count != 0 && tasks.Count >= MaxDop) 289 | { 290 | Thread.Sleep(100); 291 | var completedTasks = tasks.Where(t => t.IsCompleted).ToArray(); 292 | foreach (var completedTask in completedTasks) 293 | { 294 | try 295 | { 296 | WriteOutput(completedTask.Result); 297 | } 298 | catch (AggregateException ex) 299 | { 300 | foreach (var exception in ex.InnerExceptions) 301 | { 302 | WriteError(new ErrorRecord(exception, Globals.ErrorIdBatchFailure, ErrorCategory.WriteError, this)); 303 | } 304 | } 305 | } 306 | tasks.RemoveAll(t => completedTasks.Contains(t)); 307 | } 308 | } 309 | } 310 | 311 | private void WriteOutput(Batch batch) 312 | { 313 | if (!OutputTable) 314 | { 315 | WriteObject(batch); 316 | } 317 | else 318 | { 319 | var tbl = new System.Data.DataTable(); 320 | _ = tbl.Columns.Add("Id", typeof(string)); 321 | _ = tbl.Columns.Add("BatchId", typeof(Guid)); 322 | _ = tbl.Columns.Add("Response", typeof(string)); 323 | _ = tbl.Columns.Add("Succeeded", typeof(bool)); 324 | if (batch.Response.IsSuccessful) 325 | { 326 | foreach (var response in batch.Response.Operations) 327 | { 328 | var msg = response.Headers != null && response.Headers.TryGetValue("OData-EntityId", out var id) ? id : null; 329 | _ = tbl.Rows.Add(response.ContentId, batch.Id, msg, true); 330 | } 331 | } 332 | else 333 | { 334 | var failedOperationIds = new string[batch.Response.Operations.Count]; 335 | var i = 0; 336 | foreach (var response in batch.Response.Operations) // There will only be one op, when batch fails. 337 | { 338 | _ = tbl.Rows.Add(response.ContentId, batch.Id, response.Error.ToString(), false); 339 | failedOperationIds[i] = response.ContentId; 340 | ++i; 341 | } 342 | foreach (var op in batch.ChangeSet.Operations.Where(op => !failedOperationIds.Contains(op.ContentId))) 343 | { 344 | _ = tbl.Rows.Add(op.ContentId, batch.Id, null, false); 345 | } 346 | } 347 | WriteObject(tbl); 348 | } 349 | } 350 | 351 | private async Task> SendBatchAsync(Batch batch) 352 | { 353 | WriteInformation($"Batch-{batch.Id}[total:{batch.ChangeSet.Operations.Count()}, starting: {batch.ChangeSet.Operations.First().ContentId}] being sent...", ["dataverse"]); 354 | BatchResponse response = null; 355 | try 356 | { 357 | await taskThrottler.WaitAsync(); 358 | response = await batchProcessor.ExecuteBatchAsync(batch, CancellationToken); 359 | if (response is not null) 360 | { 361 | WriteInformation($"Batch-{batch.Id} completed.", ["dataverse"]); 362 | } 363 | else 364 | { 365 | WriteWarning($"Batch-{batch.Id} has been cancelled."); 366 | } 367 | } 368 | catch (Exception ex) 369 | { 370 | throw new BatchException($"Batch has been faild due to: {ex.Message}", ex) { Batch = batch }; 371 | //WriteError(new ErrorRecord(exception, Globals.ErrorIdBatchFailure, ErrorCategory.WriteError, this)); 372 | } 373 | finally 374 | { 375 | _ = taskThrottler.Release(); 376 | _ = Interlocked.Increment(ref batchCounter); 377 | } 378 | batch.Response = response; 379 | return batch; 380 | } 381 | 382 | protected override void Dispose(bool disposing) 383 | { 384 | if (Disposed) 385 | { return; } 386 | if (disposing) 387 | { 388 | taskThrottler?.Dispose(); 389 | } 390 | base.Dispose(disposing); 391 | } 392 | 393 | public void WriteInformation(string messageData, string[] tags) => base.WriteInformation(messageData, tags); 394 | } 395 | --------------------------------------------------------------------------------