From 9cef6846b9919396b184794e4553020835374267 Mon Sep 17 00:00:00 2001 From: Shyam Namboodiripad Date: Fri, 28 Apr 2023 02:32:59 -0700 Subject: [PATCH 1/3] Introduce object model for http requests and responses. --- ...ttpRequest_api_is_not_changed.approved.txt | 22 ++ .../HttpRequestKernel.cs | 130 +++++++---- .../HttpRequestKernelExtension.cs | 28 ++- .../HttpResponseFormattingExtensions.cs | 203 ++++++++++++++++++ ...soft.DotNet.Interactive.HttpRequest.csproj | 3 +- .../Model/HttpContent.cs | 26 +++ .../Model/HttpObjectModelFactories.cs | 72 +++++++ .../Model/HttpRequest.cs | 29 +++ .../Model/HttpResponse.cs | 35 +++ 9 files changed, 502 insertions(+), 46 deletions(-) create mode 100644 src/Microsoft.DotNet.Interactive.HttpRequest/HttpResponseFormattingExtensions.cs create mode 100644 src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpContent.cs create mode 100644 src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpObjectModelFactories.cs create mode 100644 src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpRequest.cs create mode 100644 src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpResponse.cs diff --git a/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.httpRequest_api_is_not_changed.approved.txt b/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.httpRequest_api_is_not_changed.approved.txt index 7abb7b0c2b..c286a229c0 100644 --- a/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.httpRequest_api_is_not_changed.approved.txt +++ b/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.httpRequest_api_is_not_changed.approved.txt @@ -1,4 +1,17 @@ Microsoft.DotNet.Interactive.HttpRequest + public class HttpContent + .ctor(System.String raw, System.Int64 byteLength, System.Collections.Generic.Dictionary headers, System.String contentType = null) + public System.Int64 ByteLength { get;} + public System.String ContentType { get;} + public System.Collections.Generic.Dictionary Headers { get;} + public System.String Raw { get;} + public class HttpRequest + .ctor(System.String method, System.String version, System.Collections.Generic.Dictionary headers, System.String uri = null, HttpContent content = null) + public HttpContent Content { get;} + public System.Collections.Generic.Dictionary Headers { get;} + public System.String Method { get;} + public System.String Uri { get;} + public System.String Version { get;} public class HttpRequestKernel : Microsoft.DotNet.Interactive.Kernel, Microsoft.DotNet.Interactive.IKernelCommandHandler, Microsoft.DotNet.Interactive.IKernelCommandHandler, Microsoft.DotNet.Interactive.IKernelCommandHandler, Microsoft.DotNet.Interactive.IKernelCommandHandler, Microsoft.DotNet.Interactive.IKernelCommandHandler, System.IDisposable .ctor(System.String name = null, System.Net.Http.HttpClient client = null) public System.Uri BaseAddress { get; set;} @@ -10,3 +23,12 @@ Microsoft.DotNet.Interactive.HttpRequest public class HttpRequestKernelExtension public static System.Void Load(Microsoft.DotNet.Interactive.Kernel kernel) .ctor() + public class HttpResponse + .ctor(System.Int32 statusCode, System.String reasonPhrase, System.String version, System.Collections.Generic.Dictionary headers, HttpRequest request = null, HttpContent content = null, System.Nullable elapsedMilliseconds = null) + public HttpContent Content { get;} + public System.Nullable ElapsedMilliseconds { get;} + public System.Collections.Generic.Dictionary Headers { get;} + public System.String ReasonPhrase { get;} + public HttpRequest Request { get;} + public System.Int32 StatusCode { get;} + public System.String Version { get;} diff --git a/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernel.cs b/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernel.cs index c4172ef057..7dd4db1c24 100644 --- a/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernel.cs +++ b/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernel.cs @@ -5,12 +5,14 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.CommandLine; +using System.Diagnostics; using System.Linq; -using System.Net.Http.Headers; using System.Net.Http; +using System.Net.Http.Headers; using System.Reflection; using System.Text; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.DotNet.Interactive.Commands; @@ -30,7 +32,7 @@ public class HttpRequestKernel : private readonly Argument _hostArgument = new(); private readonly Dictionary _variables = new(StringComparer.InvariantCultureIgnoreCase); - private Uri _baseAddress; + private Uri? _baseAddress; private static readonly Regex IsRequest; private static readonly Regex IsHeader; @@ -40,16 +42,14 @@ public class HttpRequestKernel : static HttpRequestKernel() { var verbs = string.Join("|", - typeof(HttpMethod).GetProperties(BindingFlags.Static | BindingFlags.Public).Select(p => p.GetValue(null).ToString())); + typeof(HttpMethod).GetProperties(BindingFlags.Static | BindingFlags.Public).Select(p => p.GetValue(null)!.ToString())); IsRequest = new Regex(@"^\s*(" + verbs + ")", RegexOptions.Multiline | RegexOptions.Compiled | RegexOptions.IgnoreCase); IsHeader = new Regex(@"^\s*(?[\w-]+):\s*(?.*)", RegexOptions.Multiline | RegexOptions.Compiled | RegexOptions.IgnoreCase); } - - - public HttpRequestKernel(string name = null, HttpClient client = null) + public HttpRequestKernel(string? name = null, HttpClient? client = null) : base(name ?? "http") { KernelInfo.LanguageName = "HTTP"; @@ -68,13 +68,16 @@ public HttpRequestKernel(string name = null, HttpClient client = null) RegisterForDisposal(_client); } - public Uri BaseAddress + public Uri? BaseAddress { get => _baseAddress; set { _baseAddress = value; - SetValue("host", value?.Host); + if (value is not null) + { + SetValue("host", value.Host); + } } } @@ -106,8 +109,8 @@ public void SetValue(string valueName, string value) public async Task HandleAsync(SubmitCode command, KernelInvocationContext context) { - var requests = ParseRequests(command.Code).ToArray(); - var diagnostics = requests.SelectMany(r => r.Diagnostics).ToArray(); + var parsedRequests = ParseRequests(command.Code).ToArray(); + var diagnostics = parsedRequests.SelectMany(r => r.Diagnostics).ToArray(); PublishDiagnostics(context, command, diagnostics); @@ -117,40 +120,63 @@ public async Task HandleAsync(SubmitCode command, KernelInvocationContext contex return; } - foreach (var httpRequest in requests) + foreach (var parsedRequest in parsedRequests) { - var message = new HttpRequestMessage(new HttpMethod(httpRequest.Verb), httpRequest.Address); - if (!string.IsNullOrWhiteSpace(httpRequest.Body)) + var requestMessage = new HttpRequestMessage(new HttpMethod(parsedRequest.Verb), parsedRequest.Address); + if (!string.IsNullOrWhiteSpace(parsedRequest.Body)) { - message.Content = new StringContent(httpRequest.Body); + requestMessage.Content = new StringContent(parsedRequest.Body); } - foreach (var kvp in httpRequest.Headers) + foreach (var kvp in parsedRequest.Headers) { switch (kvp.Key.ToLowerInvariant()) { case "content-type": - if (message.Content is null) + if (requestMessage.Content is null) { - message.Content = new StringContent(httpRequest.Body); + requestMessage.Content = new StringContent(parsedRequest.Body); } - message.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(kvp.Value); + requestMessage.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(kvp.Value); break; case "accept": - message.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(kvp.Value)); + requestMessage.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(kvp.Value)); break; case "user-agent": - message.Headers.UserAgent.Add(ProductInfoHeaderValue.Parse(kvp.Value)); + requestMessage.Headers.UserAgent.Add(ProductInfoHeaderValue.Parse(kvp.Value)); break; default: - message.Headers.Add(kvp.Key, kvp.Value); + requestMessage.Headers.Add(kvp.Key, kvp.Value); break; } } - var response = await _client.SendAsync(message); - context.Display(response, HtmlFormatter.MimeType, PlainTextFormatter.MimeType); + var response = await GetResponseWithTimingAsync(requestMessage, context.CancellationToken); + // TODO: Store response in a dictionary if it happens to be a named request. + + context.Display(response); + } + } + + private async Task GetResponseWithTimingAsync( + HttpRequestMessage requestMessage, + CancellationToken cancellationToken) + { + HttpResponse response; + var stopWatch = Stopwatch.StartNew(); + + try + { + var responseMessage = await _client.SendAsync(requestMessage, cancellationToken); + response = await responseMessage.ToHttpResponseAsync(cancellationToken); } + finally + { + stopWatch.Stop(); + } + + response.ElapsedMilliseconds = stopWatch.Elapsed.TotalMilliseconds; + return response; } private void PublishDiagnostics(KernelInvocationContext context, KernelCommand command, IEnumerable diagnostics) @@ -247,10 +273,10 @@ private static bool MightContainRequest(IEnumerable lines) return lines.Any(line => IsRequest.IsMatch(line)); //return lines.Any() && lines.Any(line => !string.IsNullOrWhiteSpace(line)); } - - private IEnumerable ParseRequests(string requests) + + private IEnumerable ParseRequests(string requests) { - var parsedRequests = new List(); + var parsedRequests = new List(); /* * A request as first verb and endpoint (optional version), this command could be multiline @@ -261,10 +287,10 @@ private IEnumerable ParseRequests(string requests) foreach (var (request, diagnostics) in InterpolateAndGetDiagnostics(requests)) { var body = new StringBuilder(); - string verb = null; - string address = null; + string? verb = null; + string? address = null; var headerValues = new Dictionary(); - var lines = request.Split(new[] {'\n'}); + var lines = request.Split(new[] { '\n' }); for (var index = 0; index < lines.Length; index++) { var line = lines[index]; @@ -275,7 +301,7 @@ private IEnumerable ParseRequests(string requests) continue; } - var parts = line.Split(new[] {' '}, StringSplitOptions.RemoveEmptyEntries); + var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); verb = parts[0].Trim(); address = parts[1].Trim(); } @@ -297,29 +323,47 @@ private IEnumerable ParseRequests(string requests) } } - var bodyText = body.ToString().Trim(); - - if (string.IsNullOrWhiteSpace(address) && BaseAddress is null) + if (string.IsNullOrWhiteSpace(verb)) { - throw new InvalidOperationException("Cannot perform HttpRequest without a valid uri."); + throw new InvalidOperationException("Cannot perform HttpRequest without a valid verb."); } - var uri = new Uri(address, UriKind.RelativeOrAbsolute); - if (!uri.IsAbsoluteUri && BaseAddress is null) + var uri = GetAbsoluteUriString(address); + var bodyText = body.ToString().Trim(); + parsedRequests.Add(new ParsedHttpRequest(verb, uri, bodyText, headerValues, diagnostics)); + } + + return parsedRequests; + } + + private string GetAbsoluteUriString(string? address) + { + Uri uri; + + if (string.IsNullOrWhiteSpace(address)) + { + uri = BaseAddress is null + ? throw new InvalidOperationException("Cannot perform HttpRequest without a valid uri.") + : BaseAddress; + } + else + { + uri = new Uri(address, UriKind.RelativeOrAbsolute); + + if (!uri.IsAbsoluteUri) { - throw new InvalidOperationException($"Cannot use relative path {uri} without a base address."); + uri = BaseAddress is null + ? throw new InvalidOperationException($"Cannot use relative path {uri} without a base address.") + : new Uri(BaseAddress, uri); } - - uri = uri.IsAbsoluteUri ? uri : new Uri(BaseAddress, uri); - parsedRequests.Add(new HttpRequest(verb, uri.AbsoluteUri, bodyText, headerValues, diagnostics)); } - return parsedRequests; + return uri.AbsoluteUri; } - private class HttpRequest + private class ParsedHttpRequest { - public HttpRequest(string verb, string address, string body, IEnumerable> headers, IEnumerable diagnostics) + public ParsedHttpRequest(string verb, string address, string body, IEnumerable> headers, IEnumerable diagnostics) { Verb = verb; Address = address; diff --git a/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernelExtension.cs b/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernelExtension.cs index 6fe370f997..b0118b9ca0 100644 --- a/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernelExtension.cs +++ b/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernelExtension.cs @@ -1,12 +1,13 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Microsoft.DotNet.Interactive.Formatting; namespace Microsoft.DotNet.Interactive.HttpRequest; -public class HttpRequestKernelExtension +public class HttpRequestKernelExtension { - public static void Load(Kernel kernel) + public static void Load(Kernel kernel) { if (kernel.RootKernel is CompositeKernel compositeKernel) { @@ -14,6 +15,8 @@ public static void Load(Kernel kernel) compositeKernel.Add(httpRequestKernel); httpRequestKernel.UseValueSharing(); + RegisterFormatters(); + KernelInvocationContext.Current?.DisplayAs($""" Added kernel `{httpRequestKernel.Name}`. Send HTTP requests using the following syntax: @@ -24,4 +27,25 @@ public static void Load(Kernel kernel) } } + private static void RegisterFormatters() + { + // TODO: Add Json. + Formatter.SetPreferredMimeTypesFor(typeof(HttpResponse), HtmlFormatter.MimeType, PlainTextFormatter.MimeType); + + Formatter.Register( + (value, context) => + { + value.FormatAsHtml(context); + return true; + }, + HtmlFormatter.MimeType); + + Formatter.Register( + (value, context) => + { + value.FormatAsPlainText(context); + return true; + }, + PlainTextFormatter.MimeType); + } } diff --git a/src/Microsoft.DotNet.Interactive.HttpRequest/HttpResponseFormattingExtensions.cs b/src/Microsoft.DotNet.Interactive.HttpRequest/HttpResponseFormattingExtensions.cs new file mode 100644 index 0000000000..a3a7c64112 --- /dev/null +++ b/src/Microsoft.DotNet.Interactive.HttpRequest/HttpResponseFormattingExtensions.cs @@ -0,0 +1,203 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.AspNetCore.Html; +using Microsoft.DotNet.Interactive.Formatting; + +namespace Microsoft.DotNet.Interactive.HttpRequest; + +internal static class HttpResponseFormattingExtensions +{ + private const string ContainerClass = "http-response-message-container"; + private const string LogContainerClass = "aspnet-logs-container"; + + private static readonly HtmlString _flexCss = new($@" + .{ContainerClass} {{ + display: flex; + flex-wrap: wrap; + }} + + .{ContainerClass} > div {{ + margin: .5em; + padding: 1em; + border: 1px solid; + }} + + .{ContainerClass} > div > h2 {{ + margin-top: 0; + }} + + .{ContainerClass} > div > h3 {{ + margin-bottom: 0; + }} + + .{LogContainerClass} {{ + margin: 0 .5em; + }} + + .{ContainerClass} summary, .{LogContainerClass} summary {{ + margin: 1em 0; + font-size: 1.17em; + font-weight: 700; + }}"); + + internal static void FormatAsHtml(this HttpResponse response, FormatContext context) + { + dynamic? requestDiv; + if (response.Request is { } request) + { + var requestUriString = request.Uri?.ToString(); + var requestHyperLink = + string.IsNullOrWhiteSpace(requestUriString) + ? "[Unknown]" + : PocketViewTags.a[href: requestUriString](requestUriString); + + var requestLine = + PocketViewTags.h3( + $"{request.Method} ", requestHyperLink, $" HTTP/{request.Version}"); + + var requestHeaders = + PocketViewTags.details( + PocketViewTags.summary("Headers"), + HeaderTable(request.Headers, request.Content?.Headers)); + + var requestBodyString = request.Content?.Raw ?? string.Empty; + var requestBodyLength = request.Content?.ByteLength ?? 0; + var requestContentType = response.Content?.ContentType; + var requestContentTypePrefix = requestContentType is null ? null : $"{requestContentType}, "; + + var requestBody = + PocketViewTags.details( + PocketViewTags.summary($"Body ({requestContentTypePrefix}{requestBodyLength} bytes)"), + PocketViewTags.pre(requestBodyString)); + + requestDiv = + PocketViewTags.div( + PocketViewTags.h2("Request"), + PocketViewTags.hr(), + requestLine, + requestHeaders, + requestBody); + } + else + { + requestDiv = PocketViewTags.div(PocketViewTags.h2("Request"), PocketViewTags.hr()); + } + + var responseLine = + PocketViewTags.h3( + $"HTTP/{response.Version} {response.StatusCode} {response.ReasonPhrase} ({response.ElapsedMilliseconds:0.##} ms)"); + + var responseHeaders = + PocketViewTags.details[open: true]( + PocketViewTags.summary("Headers"), + HeaderTable(response.Headers, response.Content?.Headers)); + + var responseBodyString = response.Content?.Raw ?? string.Empty; + var responseBodyLength = response.Content?.ByteLength ?? 0; + var responseContentType = response.Content?.ContentType; + var responseContentTypePrefix = responseContentType is null ? null : $"{responseContentType}, "; + + // TODO: Handle raw v/s formatted. + // TODO: Handle other content types like images, html and xml. + object responseObjToFormat; + try + { + responseObjToFormat = JsonDocument.Parse(responseBodyString); + } + catch (JsonException) + { + responseObjToFormat = responseBodyString; + } + + var responseBody = + PocketViewTags.details[open: true]( + PocketViewTags.summary($"Body ({responseContentTypePrefix}{responseBodyLength} bytes)"), + responseObjToFormat); + + var responseDiv = + PocketViewTags.div( + PocketViewTags.h2("Response"), + PocketViewTags.hr(), + responseLine, + responseHeaders, + responseBody); + + PocketView output = + PocketViewTags.div[@class: ContainerClass]( + PocketViewTags.style[type: "text/css"](_flexCss), + requestDiv, + responseDiv); + + output.WriteTo(context); + } + + private static dynamic HeaderTable(Dictionary headers, Dictionary? contentHeaders = null) + { + var allHeaders = contentHeaders is null ? headers : headers.Concat(contentHeaders); + + var headerTable = + PocketViewTags.table( + PocketViewTags.thead( + PocketViewTags.tr( + PocketViewTags.th("Name"), PocketViewTags.th("Value"))), + PocketViewTags.tbody( + allHeaders.Select(header => + PocketViewTags.tr( + PocketViewTags.td(header.Key), PocketViewTags.td(string.Join("; ", header.Value)))))); + + return headerTable; + } + + internal static void FormatAsPlainText(this HttpResponse response, FormatContext context) + { + if (response.Request is { } request) + { + context.Writer.WriteLine($"Request Method: {request.Method}"); + context.Writer.WriteLine($"Request URI: {request.Uri}"); + context.Writer.WriteLine($"Request Version: HTTP/{request.Version}"); + context.Writer.WriteLine(); + } + + context.Writer.WriteLine($"Status Code: {response.StatusCode} {response.ReasonPhrase}"); + context.Writer.WriteLine($"Elapsed: {response.ElapsedMilliseconds:0.##} ms"); + context.Writer.WriteLine($"Version: HTTP/{response.Version}"); + context.Writer.WriteLine($"Content Type: {response.Content?.ContentType}"); + context.Writer.WriteLine($"Content Length: {response.Content?.ByteLength ?? 0} bytes"); + context.Writer.WriteLine(); + + var headers = response.Headers; + var contentHeaders = response.Content?.Headers; + var allHeaders = contentHeaders is null ? headers : headers.Concat(contentHeaders); + + foreach (var header in allHeaders) + { + context.Writer.WriteLine($"{header.Key}: {string.Join("; ", header.Value)}"); + } + + var responseBodyString = response.Content?.Raw ?? string.Empty; + if (!string.IsNullOrWhiteSpace(responseBodyString)) + { + // TODO: Handle other content types like images, html and xml. + switch (response.Content?.ContentType) + { + case "application/json": + case "text/json": + var formatted = + JsonSerializer.Serialize( + JsonDocument.Parse(responseBodyString).RootElement, + new JsonSerializerOptions { WriteIndented = true }); + + context.Writer.WriteLine($"Body: {formatted}"); + break; + + default: + context.Writer.WriteLine($"Body: {responseBodyString}"); + break; + } + } + } +} diff --git a/src/Microsoft.DotNet.Interactive.HttpRequest/Microsoft.DotNet.Interactive.HttpRequest.csproj b/src/Microsoft.DotNet.Interactive.HttpRequest/Microsoft.DotNet.Interactive.HttpRequest.csproj index 162299bfbd..c66fca457b 100644 --- a/src/Microsoft.DotNet.Interactive.HttpRequest/Microsoft.DotNet.Interactive.HttpRequest.csproj +++ b/src/Microsoft.DotNet.Interactive.HttpRequest/Microsoft.DotNet.Interactive.HttpRequest.csproj @@ -1,11 +1,12 @@  - netstandard2.1 + net7 11 $(NoWarn);2003;CS8002;VSTHRD002;NU5100 false + enable diff --git a/src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpContent.cs b/src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpContent.cs new file mode 100644 index 0000000000..caf99b05e1 --- /dev/null +++ b/src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpContent.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.Interactive.HttpRequest; + +public class HttpContent +{ + public string Raw { get; } + public long ByteLength { get; } + public Dictionary Headers { get; } + public string? ContentType { get; } + + public HttpContent( + string raw, + long byteLength, + Dictionary headers, + string? contentType = null) + { + Raw = raw; + ByteLength = byteLength; + Headers = headers; + ContentType = contentType; + } +} diff --git a/src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpObjectModelFactories.cs b/src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpObjectModelFactories.cs new file mode 100644 index 0000000000..9a8dc25803 --- /dev/null +++ b/src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpObjectModelFactories.cs @@ -0,0 +1,72 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.Interactive.HttpRequest; + +internal static class HttpObjectModelFactories +{ + internal static async Task ToHttpRequestAsync( + this HttpRequestMessage requestMessage, + CancellationToken cancellationToken) + { + var method = requestMessage.Method.ToString(); + var version = requestMessage.Version.ToString(); + var headers = requestMessage.Headers.ToDictionary(); + var uri = requestMessage.RequestUri?.ToString(); + + HttpContent? content = null; + if (requestMessage.Content is { } requestMessageContent) + { + var contentRaw = await requestMessageContent.ReadAsStringAsync(cancellationToken); + var contentByteLength = requestMessageContent.Headers.ContentLength ?? 0; + var contentHeaders = requestMessageContent.Headers.ToDictionary(); + var contentType = requestMessageContent.Headers.ContentType?.ToString(); + content = new HttpContent(contentRaw, contentByteLength, contentHeaders, contentType); + } + + return new HttpRequest(method, version, headers, uri, content); + } + + internal static async Task ToHttpResponseAsync( + this HttpResponseMessage responseMessage, + CancellationToken cancellationToken) + { + var statusCode = (int)responseMessage.StatusCode; + var reasonPhrase = responseMessage.ReasonPhrase; + if (string.IsNullOrWhiteSpace(reasonPhrase)) + { + reasonPhrase = responseMessage.StatusCode.ToString(); + } + + var version = responseMessage.Version.ToString(); + var headers = responseMessage.Headers.ToDictionary(); + + HttpRequest? request = null; + if (responseMessage.RequestMessage is not null) + { + request = await responseMessage.RequestMessage.ToHttpRequestAsync(cancellationToken); + } + + HttpContent? content = null; + if (responseMessage.Content is { } responseMessageContent) + { + var contentRaw = await responseMessageContent.ReadAsStringAsync(cancellationToken); + var contentByteLength = responseMessageContent.Headers.ContentLength ?? 0; + var contentHeaders = responseMessageContent.Headers.ToDictionary(); + var contentType = responseMessageContent.Headers.ContentType?.ToString(); + content = new HttpContent(contentRaw, contentByteLength, contentHeaders, contentType); + } + + return new HttpResponse(statusCode, reasonPhrase, version, headers, request, content); + } + + private static Dictionary ToDictionary(this HttpHeaders headers) + => headers.ToDictionary(header => header.Key, header => header.Value.ToArray()); +} diff --git a/src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpRequest.cs b/src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpRequest.cs new file mode 100644 index 0000000000..4298206f21 --- /dev/null +++ b/src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpRequest.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.Interactive.HttpRequest; + +public class HttpRequest +{ + public string Method { get; } + public string Version { get; } + public Dictionary Headers { get; } + public string? Uri { get; } + public HttpContent? Content { get; } + + public HttpRequest( + string method, + string version, + Dictionary headers, + string? uri = null, + HttpContent? content = null) + { + Method = method; + Version = version; + Headers = headers; + Uri = uri; + Content = content; + } +} diff --git a/src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpResponse.cs b/src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpResponse.cs new file mode 100644 index 0000000000..470580c869 --- /dev/null +++ b/src/Microsoft.DotNet.Interactive.HttpRequest/Model/HttpResponse.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.Interactive.HttpRequest; + +public class HttpResponse +{ + public int StatusCode { get; } + public string ReasonPhrase { get; } + public string Version { get; } + public Dictionary Headers { get; } + public HttpRequest? Request { get; } + public HttpContent? Content { get; } + public double? ElapsedMilliseconds { get; internal set; } + + public HttpResponse( + int statusCode, + string reasonPhrase, + string version, + Dictionary headers, + HttpRequest? request = null, + HttpContent? content = null, + double? elapsedMilliseconds = null) + { + StatusCode = statusCode; + ReasonPhrase = reasonPhrase; + Version = version; + Headers = headers; + Request = request; + Content = content; + ElapsedMilliseconds = elapsedMilliseconds; + } +} From 90a40cf49dcfa913f6fba5f613d96e0030c7dfb2 Mon Sep 17 00:00:00 2001 From: Shyam Namboodiripad Date: Fri, 28 Apr 2023 10:50:41 -0700 Subject: [PATCH 2/3] Clean up public API. --- ...ilityTests.httpRequest_api_is_not_changed.approved.txt | 4 ---- .../HttpRequestKernel.cs | 8 ++++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.httpRequest_api_is_not_changed.approved.txt b/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.httpRequest_api_is_not_changed.approved.txt index c286a229c0..f86632c17b 100644 --- a/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.httpRequest_api_is_not_changed.approved.txt +++ b/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.httpRequest_api_is_not_changed.approved.txt @@ -15,10 +15,6 @@ Microsoft.DotNet.Interactive.HttpRequest public class HttpRequestKernel : Microsoft.DotNet.Interactive.Kernel, Microsoft.DotNet.Interactive.IKernelCommandHandler, Microsoft.DotNet.Interactive.IKernelCommandHandler, Microsoft.DotNet.Interactive.IKernelCommandHandler, Microsoft.DotNet.Interactive.IKernelCommandHandler, Microsoft.DotNet.Interactive.IKernelCommandHandler, System.IDisposable .ctor(System.String name = null, System.Net.Http.HttpClient client = null) public System.Uri BaseAddress { get; set;} - public System.Threading.Tasks.Task HandleAsync(Microsoft.DotNet.Interactive.Commands.RequestValue command, Microsoft.DotNet.Interactive.KernelInvocationContext context) - public System.Threading.Tasks.Task HandleAsync(Microsoft.DotNet.Interactive.Commands.SendValue command, Microsoft.DotNet.Interactive.KernelInvocationContext context) - public System.Threading.Tasks.Task HandleAsync(Microsoft.DotNet.Interactive.Commands.SubmitCode command, Microsoft.DotNet.Interactive.KernelInvocationContext context) - public System.Threading.Tasks.Task HandleAsync(Microsoft.DotNet.Interactive.Commands.RequestDiagnostics command, Microsoft.DotNet.Interactive.KernelInvocationContext context) public System.Void SetValue(System.String valueName, System.String value) public class HttpRequestKernelExtension public static System.Void Load(Microsoft.DotNet.Interactive.Kernel kernel) diff --git a/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernel.cs b/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernel.cs index 7dd4db1c24..27634cc4d0 100644 --- a/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernel.cs +++ b/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernel.cs @@ -81,7 +81,7 @@ public Uri? BaseAddress } } - public Task HandleAsync(RequestValue command, KernelInvocationContext context) + Task IKernelCommandHandler.HandleAsync(RequestValue command, KernelInvocationContext context) { if (_variables.TryGetValue(command.Name, out var value)) { @@ -96,7 +96,7 @@ public Task HandleAsync(RequestValue command, KernelInvocationContext context) return Task.CompletedTask; } - public Task HandleAsync(SendValue command, KernelInvocationContext context) + Task IKernelCommandHandler.HandleAsync(SendValue command, KernelInvocationContext context) { SetValue(command.Name, command.FormattedValue.Value); return Task.CompletedTask; @@ -107,7 +107,7 @@ public void SetValue(string valueName, string value) _variables[valueName] = value; } - public async Task HandleAsync(SubmitCode command, KernelInvocationContext context) + async Task IKernelCommandHandler.HandleAsync(SubmitCode command, KernelInvocationContext context) { var parsedRequests = ParseRequests(command.Code).ToArray(); var diagnostics = parsedRequests.SelectMany(r => r.Diagnostics).ToArray(); @@ -189,7 +189,7 @@ private void PublishDiagnostics(KernelInvocationContext context, KernelCommand c context.Publish(new DiagnosticsProduced(diagnostics, command, formattedDiagnostics)); } - public Task HandleAsync(RequestDiagnostics command, KernelInvocationContext context) + Task IKernelCommandHandler.HandleAsync(RequestDiagnostics command, KernelInvocationContext context) { var requestsAndDiagnostics = InterpolateAndGetDiagnostics(command.Code); var diagnostics = requestsAndDiagnostics.SelectMany(r => r.Diagnostics); From ae0960a1a8e3f4db02ac6eeda685511db74ec168 Mon Sep 17 00:00:00 2001 From: Shyam Namboodiripad Date: Fri, 28 Apr 2023 11:39:40 -0700 Subject: [PATCH 3/3] Add test for http formatter. --- ...ttpRequest_api_is_not_changed.approved.txt | 2 +- .../HttpRequestKernelTests.cs | 47 ++++++++++++++++--- .../HttpRequestKernelExtension.cs | 8 ++-- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.httpRequest_api_is_not_changed.approved.txt b/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.httpRequest_api_is_not_changed.approved.txt index f86632c17b..54b47e7169 100644 --- a/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.httpRequest_api_is_not_changed.approved.txt +++ b/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.httpRequest_api_is_not_changed.approved.txt @@ -17,7 +17,7 @@ Microsoft.DotNet.Interactive.HttpRequest public System.Uri BaseAddress { get; set;} public System.Void SetValue(System.String valueName, System.String value) public class HttpRequestKernelExtension - public static System.Void Load(Microsoft.DotNet.Interactive.Kernel kernel) + public static System.Void Load(Microsoft.DotNet.Interactive.Kernel kernel, System.Net.Http.HttpClient httpClient = null) .ctor() public class HttpResponse .ctor(System.Int32 statusCode, System.String reasonPhrase, System.String version, System.Collections.Generic.Dictionary headers, HttpRequest request = null, HttpContent content = null, System.Nullable elapsedMilliseconds = null) diff --git a/src/Microsoft.DotNet.Interactive.HttpRequest.Tests/HttpRequestKernelTests.cs b/src/Microsoft.DotNet.Interactive.HttpRequest.Tests/HttpRequestKernelTests.cs index c91878ec96..0b266817af 100644 --- a/src/Microsoft.DotNet.Interactive.HttpRequest.Tests/HttpRequestKernelTests.cs +++ b/src/Microsoft.DotNet.Interactive.HttpRequest.Tests/HttpRequestKernelTests.cs @@ -10,6 +10,7 @@ using FluentAssertions; using Microsoft.DotNet.Interactive.Commands; using Microsoft.DotNet.Interactive.Events; +using Microsoft.DotNet.Interactive.Formatting; using Microsoft.DotNet.Interactive.Tests.Utility; using Xunit; @@ -17,6 +18,11 @@ namespace Microsoft.DotNet.Interactive.HttpRequest.Tests; public class HttpRequestKernelTests { + public HttpRequestKernelTests() + { + Formatter.ResetToDefault(); + } + [Theory] [InlineData("GET")] [InlineData("PUT")] @@ -41,7 +47,6 @@ public async Task supports_verbs(string verb) result.Events.Should().NotContainErrors(); request.Method.Method.Should().Be(verb); - } [Fact] @@ -51,7 +56,7 @@ public async Task requires_base_address_when_using_relative_uris() var result = await kernel.SendAsync(new SubmitCode("get /relativePath")); - var error = result.Events.Should().ContainSingle().Which; + var error = result.Events.Should().ContainSingle().Which; error.Message.Should().Contain("Cannot use relative path /relativePath without a base address."); } @@ -69,7 +74,7 @@ public async Task ignores_base_address_when_using_absolute_paths() var client = new HttpClient(handler); using var kernel = new HttpRequestKernel(client: client); kernel.BaseAddress = new Uri("http://example.com"); - + var result = await kernel.SendAsync(new SubmitCode("get https://anotherlocation.com/endpoint")); result.Events.Should().NotContainErrors(); @@ -89,7 +94,7 @@ public async Task can_replace_symbols() }); var client = new HttpClient(handler); using var kernel = new HttpRequestKernel(client: client); - + kernel.SetValue("my_host", "my.host.com"); var result = await kernel.SendAsync(new SubmitCode("get https://{{my_host}}:1200/endpoint")); @@ -123,7 +128,7 @@ public async Task can_use_base_address_to_resolve_host_symbol() [Fact] public async Task can_handle_multiple_request_in_a_single_submission() { - List requests = new (); + List requests = new(); var handler = new InterceptingHttpMessageHandler((message, _) => { requests.Add(message); @@ -140,7 +145,7 @@ public async Task can_handle_multiple_request_in_a_single_submission() result.Events.Should().NotContainErrors(); - requests.Select(r => r.RequestUri.AbsoluteUri).ToArray().Should().BeEquivalentTo(new []{ "https://location1.com:1200/endpoint", "https://location2.com:1200/endpoint" }); + requests.Select(r => r.RequestUri.AbsoluteUri).ToArray().Should().BeEquivalentTo(new[] { "https://location1.com:1200/endpoint", "https://location2.com:1200/endpoint" }); } [Fact] @@ -227,7 +232,7 @@ public async Task can_use_symbols_in_body() }); var client = new HttpClient(handler); using var kernel = new HttpRequestKernel(client: client); - kernel.SetValue("one","1"); + kernel.SetValue("one", "1"); var result = await kernel.SendAsync(new SubmitCode(@" post https://location1.com:1200/endpoint Authorization: Basic username password @@ -352,4 +357,32 @@ public async Task multiple_diagnostics_are_returned_from_the_same_submission() diagnostics.Diagnostics.Should().HaveCount(2); } + + [Fact] + public async Task produces_json_html_and_plain_text_formatted_values() + { + HttpRequestMessage request = null; + var handler = new InterceptingHttpMessageHandler((message, _) => + { + request = message; + var response = new HttpResponseMessage(HttpStatusCode.OK); + response.RequestMessage = message; + return Task.FromResult(response); + }); + var client = new HttpClient(handler); + + using var root = new CompositeKernel(); + HttpRequestKernelExtension.Load(root, client); + var kernel = root.FindKernels(k => k is HttpRequestKernel).Single(); + + var result = await kernel.SendAsync(new SubmitCode($"GET http://testuri.ninja")); + + result.Events.Should().NotContainErrors(); + + var displayEvent = + result.Events.Should().ContainSingle().Which.FormattedValues.Should() + .Contain(f => f.MimeType == HtmlFormatter.MimeType).And + .Contain(f => f.MimeType == PlainTextFormatter.MimeType).And + .Contain(f => f.MimeType == JsonFormatter.MimeType); + } } diff --git a/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernelExtension.cs b/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernelExtension.cs index b0118b9ca0..da058f8528 100644 --- a/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernelExtension.cs +++ b/src/Microsoft.DotNet.Interactive.HttpRequest/HttpRequestKernelExtension.cs @@ -1,17 +1,18 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Net.Http; using Microsoft.DotNet.Interactive.Formatting; namespace Microsoft.DotNet.Interactive.HttpRequest; public class HttpRequestKernelExtension { - public static void Load(Kernel kernel) + public static void Load(Kernel kernel, HttpClient? httpClient = null) { if (kernel.RootKernel is CompositeKernel compositeKernel) { - var httpRequestKernel = new HttpRequestKernel(); + var httpRequestKernel = new HttpRequestKernel(client: httpClient); compositeKernel.Add(httpRequestKernel); httpRequestKernel.UseValueSharing(); @@ -29,8 +30,7 @@ public static void Load(Kernel kernel) private static void RegisterFormatters() { - // TODO: Add Json. - Formatter.SetPreferredMimeTypesFor(typeof(HttpResponse), HtmlFormatter.MimeType, PlainTextFormatter.MimeType); + Formatter.SetPreferredMimeTypesFor(typeof(HttpResponse), HtmlFormatter.MimeType, PlainTextFormatter.MimeType, JsonFormatter.MimeType); Formatter.Register( (value, context) =>