diff --git a/tools/Custom/EventExtensions.cs b/tools/Custom/EventExtensions.cs new file mode 100644 index 00000000000..df05433e553 --- /dev/null +++ b/tools/Custom/EventExtensions.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +namespace Microsoft.Graph.PowerShell +{ + using System; + using System.Text.RegularExpressions; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Graph.PowerShell.Runtime; + + public static class EventExtensions + { + /// + /// Print event details to the provided stream + /// + /// The event data to print + /// The delegate for signaling events to the runtime + /// The cancellation token for the request + /// The name of the stream to print data to + /// The name of the event to be printed + public static async void Print(this Func getEventData, Func, Task> signal, CancellationToken token, string streamName, string eventName) + { + var eventDisplayName = EventFactory.SplitPascalCase(eventName).ToUpperInvariant(); + var data = EventDataConverter.ConvertFrom(getEventData()); // also, we manually use our TypeConverter to return an appropriate type + if (data.Id != Events.Verbose && data.Id != Events.Warning && data.Id != Events.Debug && data.Id != Events.Information && data.Id != Events.Error) + { + await signal(streamName, token, () => EventFactory.CreateLogEvent($"{eventDisplayName} The contents are '{data?.Id}' and '{data?.Message}'")); + if (data != null) + { + await signal(streamName, token, () => EventFactory.CreateLogEvent($"{eventDisplayName} Parameter: '{data.Parameter}'\n{eventDisplayName} RequestMessage '{data.RequestMessage}'\n{eventDisplayName} Response: '{data.ResponseMessage}'\n{eventDisplayName} Value: '{data.Value}'")); + await signal(streamName, token, () => EventFactory.CreateLogEvent($"{eventDisplayName} ExtendedData Type: '{data.ExtendedData?.GetType()}'\n{eventDisplayName} ExtendedData '{data.ExtendedData}'")); + } + } + } + } +} \ No newline at end of file diff --git a/tools/Custom/EventFactory.cs b/tools/Custom/EventFactory.cs new file mode 100644 index 00000000000..2e2966de6e8 --- /dev/null +++ b/tools/Custom/EventFactory.cs @@ -0,0 +1,73 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Graph.PowerShell.Runtime; + +namespace Microsoft.Graph.PowerShell +{ + public static class EventFactory + { + /// + /// Create a tracing event containing a string message + /// + /// The string message to include in event data + /// Valid EventData containing the message + public static EventData CreateLogEvent(Task message) + { + return new EventData + { + Id = Guid.NewGuid().ToString(), + Message = message.Result + }; + } + + /// + /// Create a new debug message event + /// + /// The message + /// An event containing the debug message + public static EventData CreateDebugEvent(string message) + { + return new EventData + { + Id = Events.Debug, + Message = message + }; + } + + /// + /// Create a new debug message event + /// + /// The message + /// An event containing the debug message + public static EventData CreateWarningEvent(string message) + { + return new EventData + { + Id = Events.Warning, + Message = message + }; + } + public static string SplitPascalCase(string word) + { + var regex = new Regex("([a-z]+)([A-Z])"); + var output = regex.Replace(word, "$1 $2"); + regex = new Regex("([A-Z])([A-Z][a-z])"); + return regex.Replace(output, "$1 $2"); + } + + public static EventArgs CreateLogEvent(string message) + { + return new EventData + { + Id = Guid.NewGuid().ToString(), + Message = message + }; + } + } +} \ No newline at end of file diff --git a/tools/Custom/HttpMessageFormatter.cs b/tools/Custom/HttpMessageFormatter.cs new file mode 100644 index 00000000000..8bd25ea08a8 --- /dev/null +++ b/tools/Custom/HttpMessageFormatter.cs @@ -0,0 +1,360 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.Graph.PowerShell +{ + /// + /// Derived class which can encapsulate an + /// or an as an entity with media type "application/http". + /// + internal class HttpMessageFormatter : HttpContent + { + private const string SP = " "; + private const string ColonSP = ": "; + private const string CRLF = "\r\n"; + private const string CommaSeparator = ", "; + + private const int DefaultHeaderAllocation = 2 * 1024; + + private const string DefaultMediaType = "application/http"; + + private const string MsgTypeParameter = "msgtype"; + private const string DefaultRequestMsgType = "request"; + private const string DefaultResponseMsgType = "response"; + + // Set of header fields that only support single values such as Set-Cookie. + private static readonly HashSet _singleValueHeaderFields = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Cookie", + "Set-Cookie", + "X-Powered-By", + }; + + // Set of header fields that should get serialized as space-separated values such as User-Agent. + private static readonly HashSet _spaceSeparatedValueHeaderFields = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "User-Agent", + }; + + // Set of header fields that should not get serialized + private static readonly HashSet _neverSerializedHeaderFields = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "SdkVersion", + "FeatureFlag", + "Authorization", + "Cache-Control", + "Transfer-Encoding", + "Duration", + "Strict-Transport-Security", + "Date" + }; + + private bool _contentConsumed; + private Lazy> _streamTask; + + /// + /// Initializes a new instance of the class encapsulating an + /// . + /// + /// The instance to encapsulate. + public HttpMessageFormatter(HttpRequestMessage httpRequest) + { + HttpRequestMessage = httpRequest ?? throw new ArgumentNullException(nameof(httpRequest)); + Headers.ContentType = new MediaTypeHeaderValue(DefaultMediaType); + Headers.ContentType.Parameters.Add(new NameValueHeaderValue(MsgTypeParameter, DefaultRequestMsgType)); + + InitializeStreamTask(); + } + + /// + /// Initializes a new instance of the class encapsulating an + /// . + /// + /// The instance to encapsulate. + public HttpMessageFormatter(HttpResponseMessage httpResponse) + { + HttpResponseMessage = httpResponse ?? throw new ArgumentNullException(nameof(httpResponse)); + Headers.ContentType = new MediaTypeHeaderValue(DefaultMediaType); + Headers.ContentType.Parameters.Add(new NameValueHeaderValue(MsgTypeParameter, DefaultResponseMsgType)); + + InitializeStreamTask(); + } + + private HttpContent Content + { + get { return HttpRequestMessage != null ? HttpRequestMessage.Content : HttpResponseMessage.Content; } + } + + /// + /// Gets the HTTP request message. + /// + public HttpRequestMessage HttpRequestMessage { get; private set; } + + /// + /// Gets the HTTP response message. + /// + public HttpResponseMessage HttpResponseMessage { get; private set; } + + private void InitializeStreamTask() + { + _streamTask = new Lazy>(() => Content?.ReadAsStreamAsync()); + } + + /// + /// Validates whether the content contains an HTTP Request or an HTTP Response. + /// + /// The content to validate. + /// if set to true if the content is either an HTTP Request or an HTTP Response. + /// Indicates whether validation failure should result in an or not. + /// true if content is either an HTTP Request or an HTTP Response + internal static bool ValidateHttpMessageContent(HttpContent content, bool isRequest, bool throwOnError) + { + if (content == null) + { + throw new ArgumentNullException(nameof(content)); + } + + MediaTypeHeaderValue contentType = content.Headers.ContentType; + if (contentType != null) + { + if (!contentType.MediaType.Equals(DefaultMediaType, StringComparison.OrdinalIgnoreCase)) + { + if (throwOnError) + { + throw new ArgumentException("HttpMessageInvalidMediaType", nameof(content)); + } + else + { + return false; + } + } + + foreach (NameValueHeaderValue parameter in contentType.Parameters) + { + if (parameter.Name.Equals(MsgTypeParameter, StringComparison.OrdinalIgnoreCase)) + { + string msgType = UnquoteToken(parameter.Value); + if (!msgType.Equals(isRequest ? DefaultRequestMsgType : DefaultResponseMsgType, StringComparison.OrdinalIgnoreCase)) + { + if (throwOnError) + { + throw new ArgumentException("HttpMessageInvalidMediaType", nameof(content)); + } + else + { + return false; + } + } + + return true; + } + } + } + + if (throwOnError) + { + throw new ArgumentException("HttpMessageInvalidMediaType", nameof(content)); + } + else + { + return false; + } + } + + + public static string UnquoteToken(string token) + { + if (String.IsNullOrWhiteSpace(token)) + { + return token; + } + + if (token.StartsWith("\"", StringComparison.Ordinal) && token.EndsWith("\"", StringComparison.Ordinal) && token.Length > 1) + { + return token.Substring(1, token.Length - 2); + } + + return token; + } + + + /// + /// Asynchronously serializes the object's content to the given . + /// + /// The to which to write. + /// The associated . + /// A instance that is asynchronously serializing the object's content. + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + if (stream == null) + { + throw new ArgumentNullException(nameof(stream)); + } + + byte[] header = SerializeHeader(); + await stream.WriteAsync(header, 0, header.Length); + + if (Content != null) + { + Stream readStream = await _streamTask.Value; + ValidateStreamForReading(readStream); + await Content.CopyToAsync(stream); + } + } + + /// + /// Computes the length of the stream if possible. + /// + /// The computed length of the stream. + /// true if the length has been computed; otherwise false. + protected override bool TryComputeLength(out long length) + { + // We have four states we could be in: + // 1. We have content, but the task is still running or finished without success + // 2. We have content, the task has finished successfully, and the stream came back as a null or non-seekable + // 3. We have content, the task has finished successfully, and the stream is seekable, so we know its length + // 4. We don't have content (streamTask.Value == null) + // + // For #1 and #2, we return false. + // For #3, we return true & the size of our headers + the content length + // For #4, we return true & the size of our headers + + bool hasContent = _streamTask.Value != null; + length = 0; + + // Cases #1, #2, #3 + // We serialize header to a StringBuilder so that we can determine the length + // following the pattern for HttpContent to try and determine the message length. + // The perf overhead is no larger than for the other HttpContent implementations. + byte[] header = SerializeHeader(); + length += header.Length; + return true; + } + + /// + /// Serializes the HTTP request line. + /// + /// Where to write the request line. + /// The HTTP request. + private static void SerializeRequestLine(StringBuilder message, HttpRequestMessage httpRequest) + { + Contract.Assert(message != null, "message cannot be null"); + message.Append(httpRequest.Method + SP); + message.Append(httpRequest.RequestUri.PathAndQuery + SP); + message.Append($"HTTP/{(httpRequest.Version != null ? httpRequest.Version.ToString(2) : "1.1")}{CRLF}"); + + // Only insert host header if not already present. + if (httpRequest.Headers.Host == null) + { + message.Append($"HTTP{ColonSP}{httpRequest.RequestUri.Authority}{CRLF}"); + } + } + + /// + /// Serializes the HTTP status line. + /// + /// Where to write the status line. + /// The HTTP response. + private static void SerializeStatusLine(StringBuilder message, HttpResponseMessage httpResponse) + { + Contract.Assert(message != null, "message cannot be null"); + message.Append($"HTTP/{(httpResponse.Version != null ? httpResponse.Version.ToString(2) : "1.1")}{SP}"); + message.Append((int)httpResponse.StatusCode + SP); + message.Append(httpResponse.ReasonPhrase + CRLF); + } + + /// + /// Serializes the header fields. + /// + /// Where to write the status line. + /// The headers to write. + private static void SerializeHeaderFields(StringBuilder message, HttpHeaders headers) + { + Contract.Assert(message != null, "message cannot be null"); + if (headers != null) + { + foreach (KeyValuePair> header in headers) + { + if (_neverSerializedHeaderFields.Contains(header.Key)) + { + continue; + } + if (_singleValueHeaderFields.Contains(header.Key)) + { + foreach (string value in header.Value) + { + message.Append(header.Key + ColonSP + value + CRLF); + } + } + else if (_spaceSeparatedValueHeaderFields.Contains(header.Key)) + { + message.Append(header.Key + ColonSP + String.Join(SP, header.Value) + CRLF); + } + else + { + message.Append(header.Key + ColonSP + String.Join(CommaSeparator, header.Value) + CRLF); + } + } + } + } + + private byte[] SerializeHeader() + { + StringBuilder message = new StringBuilder(DefaultHeaderAllocation); + HttpHeaders headers; + HttpContent content; + if (HttpRequestMessage != null) + { + SerializeRequestLine(message, HttpRequestMessage); + headers = HttpRequestMessage.Headers; + content = HttpRequestMessage.Content; + } + else + { + SerializeStatusLine(message, HttpResponseMessage); + headers = HttpResponseMessage.Headers; + content = HttpResponseMessage.Content; + } + + SerializeHeaderFields(message, headers); + if (content != null) + { + SerializeHeaderFields(message, content.Headers); + } + + message.Append(CRLF); + return Encoding.UTF8.GetBytes(message.ToString()); + } + + private void ValidateStreamForReading(Stream stream) + { + // If the content needs to be written to a target stream a 2nd time, then the stream must support + // seeking (e.g. a FileStream), otherwise the stream can't be copied a second time to a target + // stream (e.g. a NetworkStream). + if (_contentConsumed) + { + if (stream != null && stream.CanRead) + { + stream.Position = 0; + } + else + { + throw new InvalidOperationException("HttpMessageContentAlreadyRead"); + } + } + + _contentConsumed = true; + } + } +} \ No newline at end of file diff --git a/tools/Custom/Module.cs b/tools/Custom/Module.cs index 1646a82143d..4766b09682c 100644 --- a/tools/Custom/Module.cs +++ b/tools/Custom/Module.cs @@ -1,11 +1,19 @@ // ------------------------------------------------------------------------------ // Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. // ------------------------------------------------------------------------------ + +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Graph.PowerShell.Runtime; + namespace Microsoft.Graph.PowerShell { using Microsoft.Graph.PowerShell.Authentication; using Microsoft.Graph.PowerShell.Authentication.Helpers; using Microsoft.Graph.PowerShell.Authentication.Models; + using System; using System.Linq; using System.Management.Automation; @@ -19,7 +27,70 @@ public partial class Module public string ProfileName { get; set; } = "v1.0-beta"; partial void BeforeCreatePipeline(System.Management.Automation.InvocationInfo invocationInfo, ref Runtime.HttpPipeline pipeline) { + // Call Init to trigger any custom initialization needed after + // module load and before pipeline is setup and used. + Init(); pipeline = new Runtime.HttpPipeline(new Runtime.HttpClientFactory(HttpHelpers.GetGraphHttpClient())); } + + /// + /// Any needed Custom Initialization. + /// + partial void CustomInit() + { + this.EventListener = EventHandler; + } + + /// + /// Common Module Event Listener, allows to handle emitted by CmdLets + /// + /// The ID of the event + /// The cancellation token for the event + /// A delegate to get the detailed event data + /// The callback for the event dispatcher + /// The from the cmdlet + /// The cmdlet's parameterset name + /// the exception that is being thrown (if available) + /// + /// A that will be complete when handling of the event is completed. + /// + public async Task EventHandler(string id, CancellationToken cancellationToken, Func getEventData, Func, Task> signal, InvocationInfo invocationInfo, string parameterSetName, System.Exception exception) + { + switch (id) + { + case Events.Finally: + await Finally(id, cancellationToken, getEventData, signal); + break; + default: + getEventData.Print(signal, cancellationToken, Events.Information, id); + break; + } + } + + /// + /// Handles the Finally event, which is called just before Request and Response are disposed. + /// + /// The ID of the event + /// The cancellation token for the event + /// A delegate to get the detailed event data + /// The callback for the event dispatcher + /// + /// A that will be complete when handling of the event is completed. + /// + private async Task Finally(string id, CancellationToken cancellationToken, Func getEventData, Func, Task> signal) + { + using (Extensions.NoSynchronizationContext) + { + var eventData = EventDataConverter.ConvertFrom(getEventData()); + using (var requestFormatter = new HttpMessageFormatter(eventData.RequestMessage as HttpRequestMessage)) + using (var responseFormatter = new HttpMessageFormatter(eventData.ResponseMessage as HttpResponseMessage)) + { + var requestString = await requestFormatter.ReadAsStringAsync(); + var responseString = await responseFormatter.ReadAsStringAsync(); + await signal(Events.Debug, cancellationToken, () => EventFactory.CreateLogEvent(requestString)); + await signal(Events.Debug, cancellationToken, () => EventFactory.CreateLogEvent(responseString)); + } + } + } } }