diff --git a/src/Authentication/Authentication.Test/Helpers/PSObjectJsonConverterTests.cs b/src/Authentication/Authentication.Test/Helpers/PSObjectJsonConverterTests.cs new file mode 100644 index 00000000000..3baba24318e --- /dev/null +++ b/src/Authentication/Authentication.Test/Helpers/PSObjectJsonConverterTests.cs @@ -0,0 +1,148 @@ +using System.Collections; +using System.Management.Automation; +using System.Net.Http; + +using Microsoft.Graph.PowerShell.Authentication.Cmdlets; +using Microsoft.Graph.PowerShell.Authentication.Helpers; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using Xunit; + +namespace Microsoft.Graph.Authentication.Test.Helpers +{ + public class PSObjectJsonConverterTests + { + [Fact] + public void ShouldSerializePSObjectWrappedStringAsPlainString() + { + // Mimics a value produced by the PowerShell pipeline, e.g. bare $_ in ForEach-Object. + // See https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/3654. + var body = new Hashtable + { + { + "message", new Hashtable + { + { + "toRecipients", new object[] + { + new Hashtable + { + { + "emailAddress", new Hashtable + { + { "address", PSObject.AsPSObject("recipient@example.com") } + } + } + } + } + } + } + }, + { "saveToSentItems", true } + }; + + var json = JsonConvert.SerializeObject(body, new PSObjectJsonConverter()); + + var parsedBody = JObject.Parse(json); + Assert.Equal("recipient@example.com", + parsedBody.SelectToken("message.toRecipients[0].emailAddress.address").Value()); + Assert.True(parsedBody.SelectToken("saveToSentItems").Value()); + } + + [Fact] + public void ShouldSerializePSObjectWrappedPrimitivesAsUnderlyingValues() + { + var body = new Hashtable + { + { "count", PSObject.AsPSObject(42) }, + { "enabled", PSObject.AsPSObject(true) } + }; + + var json = JsonConvert.SerializeObject(body, new PSObjectJsonConverter()); + + var parsedBody = JObject.Parse(json); + Assert.Equal(42, parsedBody.SelectToken("count").Value()); + Assert.True(parsedBody.SelectToken("enabled").Value()); + } + + [Fact] + public void ShouldSerializePSObjectWrappedDictionary() + { + var body = new Hashtable + { + { + "emailAddress", PSObject.AsPSObject(new Hashtable + { + { "address", PSObject.AsPSObject("recipient@example.com") } + }) + } + }; + + var json = JsonConvert.SerializeObject(body, new PSObjectJsonConverter()); + + var parsedBody = JObject.Parse(json); + Assert.Equal("recipient@example.com", + parsedBody.SelectToken("emailAddress.address").Value()); + } + + [Fact] + public void ShouldSerializePSCustomObjectAsJsonObject() + { + var emailAddress = new PSObject(); + emailAddress.Properties.Add(new PSNoteProperty("address", PSObject.AsPSObject("recipient@example.com"))); + var body = new Hashtable + { + { "emailAddress", emailAddress } + }; + + var json = JsonConvert.SerializeObject(body, new PSObjectJsonConverter()); + + var parsedBody = JObject.Parse(json); + Assert.Equal("recipient@example.com", + parsedBody.SelectToken("emailAddress.address").Value()); + } + + [Fact] + public void SetRequestContentShouldSerializeDictionaryWithPSObjectWrappedValues() + { + var cmdlet = new InvokeMgGraphRequest(); + using (var request = new HttpRequestMessage(HttpMethod.Post, + "https://graph.microsoft.com/v1.0/users/sender@example.com/sendMail")) + { + var body = new Hashtable + { + { + "message", new Hashtable + { + { + "toRecipients", new object[] + { + new Hashtable + { + { + "emailAddress", new Hashtable + { + { "address", PSObject.AsPSObject("recipient@example.com") } + } + } + } + } + } + } + }, + { "saveToSentItems", true } + }; + + var contentLength = cmdlet.SetRequestContent(request, body); + + Assert.True(contentLength > 0); + var json = request.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + var parsedBody = JObject.Parse(json); + Assert.Equal("recipient@example.com", + parsedBody.SelectToken("message.toRecipients[0].emailAddress.address").Value()); + } + } + } +} diff --git a/src/Authentication/Authentication.Test/Microsoft.Graph.Authentication.Test.csproj b/src/Authentication/Authentication.Test/Microsoft.Graph.Authentication.Test.csproj index ccaa28e8fb4..2dda94b996d 100644 --- a/src/Authentication/Authentication.Test/Microsoft.Graph.Authentication.Test.csproj +++ b/src/Authentication/Authentication.Test/Microsoft.Graph.Authentication.Test.csproj @@ -7,7 +7,11 @@ - + + diff --git a/src/Authentication/Authentication/Cmdlets/InvokeMgGraphRequest.cs b/src/Authentication/Authentication/Cmdlets/InvokeMgGraphRequest.cs index 4e16b462486..b93e309f3dd 100644 --- a/src/Authentication/Authentication/Cmdlets/InvokeMgGraphRequest.cs +++ b/src/Authentication/Authentication/Cmdlets/InvokeMgGraphRequest.cs @@ -567,7 +567,7 @@ private async Task GetResponseAsync(HttpClient client, Http /// /// /// - private long SetRequestContent(HttpRequestMessage request, IDictionary content) + internal long SetRequestContent(HttpRequestMessage request, IDictionary content) { if (request == null) { @@ -579,8 +579,10 @@ private long SetRequestContent(HttpRequestMessage request, IDictionary content) throw new ArgumentNullException(nameof(content)); } - // Covert all dictionaries to Json - var body = JsonConvert.SerializeObject(content); + // Convert all dictionaries to Json. + // Unwrap PSObject-wrapped values (e.g. values produced by the PowerShell pipeline) so the + // underlying CLR values are serialized instead of their PSObject wrappers. See https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/3654. + var body = JsonConvert.SerializeObject(content, new PSObjectJsonConverter()); return SetRequestContent(request, body); } diff --git a/src/Authentication/Authentication/Helpers/PSObjectJsonConverter.cs b/src/Authentication/Authentication/Helpers/PSObjectJsonConverter.cs new file mode 100644 index 00000000000..a472d94f364 --- /dev/null +++ b/src/Authentication/Authentication/Helpers/PSObjectJsonConverter.cs @@ -0,0 +1,56 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using Newtonsoft.Json; +using System; +using System.Management.Automation; + +namespace Microsoft.Graph.PowerShell.Authentication.Helpers +{ + /// + /// Serializes values by unwrapping them to their underlying base object. + /// PowerShell wraps values that pass through the pipeline (e.g. bare $_ in ForEach-Object) in a + /// . Without this converter, Newtonsoft.Json reflects over the PowerShell + /// adapted members of the wrapper (such as the Chars indexed property on strings) instead of the + /// underlying value and fails with a self-referencing loop error. + /// + internal class PSObjectJsonConverter : JsonConverter + { + public override bool CanRead => false; + + public override bool CanConvert(Type objectType) + { + return typeof(PSObject).IsAssignableFrom(objectType); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var psObject = (PSObject)value; + if (psObject.BaseObject is PSCustomObject) + { + // Pure custom objects (e.g. [pscustomobject]@{ ... }) have no underlying CLR object + // to fall back on; project their properties into a JSON object instead. + writer.WriteStartObject(); + foreach (var property in psObject.Properties) + { + writer.WritePropertyName(property.Name); + serializer.Serialize(writer, property.Value); + } + writer.WriteEndObject(); + } + else + { + // Serialize the underlying CLR object. Nested PSObject-wrapped values (e.g. inside + // dictionaries or collections) are routed back through this converter by the serializer. + serializer.Serialize(writer, psObject.BaseObject); + } + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + throw new NotSupportedException(); + } + } +} +