diff --git a/src/Http/Http.Extensions/ref/Directory.Build.props b/src/Http/Http.Extensions/ref/Directory.Build.props new file mode 100644 index 000000000000..fb5774513805 --- /dev/null +++ b/src/Http/Http.Extensions/ref/Directory.Build.props @@ -0,0 +1,7 @@ + + + + + annotations + + diff --git a/src/Http/Http.Extensions/ref/Microsoft.AspNetCore.Http.Extensions.csproj b/src/Http/Http.Extensions/ref/Microsoft.AspNetCore.Http.Extensions.csproj index 4164c67b3a45..4ebba5f8da2b 100644 --- a/src/Http/Http.Extensions/ref/Microsoft.AspNetCore.Http.Extensions.csproj +++ b/src/Http/Http.Extensions/ref/Microsoft.AspNetCore.Http.Extensions.csproj @@ -5,6 +5,7 @@ + diff --git a/src/Http/Http.Extensions/ref/Microsoft.AspNetCore.Http.Extensions.netcoreapp.cs b/src/Http/Http.Extensions/ref/Microsoft.AspNetCore.Http.Extensions.netcoreapp.cs index 7fa291f591fe..e92027d21e6c 100644 --- a/src/Http/Http.Extensions/ref/Microsoft.AspNetCore.Http.Extensions.netcoreapp.cs +++ b/src/Http/Http.Extensions/ref/Microsoft.AspNetCore.Http.Extensions.netcoreapp.cs @@ -13,6 +13,10 @@ public static partial class HttpContextServerVariableExtensions { public static string GetServerVariable(this Microsoft.AspNetCore.Http.HttpContext context, string variableName) { throw null; } } + public static partial class HttpRequestExtensions + { + public static bool HasJsonContentType(this Microsoft.AspNetCore.Http.HttpRequest request) { throw null; } + } public static partial class ResponseExtensions { public static void Clear(this Microsoft.AspNetCore.Http.HttpResponse response) { } @@ -127,3 +131,29 @@ public void Set(string name, object value) { } public void SetList(string name, System.Collections.Generic.IList values) { } } } +namespace Microsoft.AspNetCore.Http.Json +{ + public static partial class HttpRequestJsonExtensions + { + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.ValueTask ReadFromJsonAsync(this Microsoft.AspNetCore.Http.HttpRequest request, System.Type type, System.Text.Json.JsonSerializerOptions? options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.ValueTask ReadFromJsonAsync(this Microsoft.AspNetCore.Http.HttpRequest request, System.Type type, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.ValueTask ReadFromJsonAsync(this Microsoft.AspNetCore.Http.HttpRequest request, System.Text.Json.JsonSerializerOptions? options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.ValueTask ReadFromJsonAsync(this Microsoft.AspNetCore.Http.HttpRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + } + public static partial class HttpResponseJsonExtensions + { + public static System.Threading.Tasks.Task WriteAsJsonAsync(this Microsoft.AspNetCore.Http.HttpResponse response, object? value, System.Type type, System.Text.Json.JsonSerializerOptions? options, string? contentType, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task WriteAsJsonAsync(this Microsoft.AspNetCore.Http.HttpResponse response, object? value, System.Type type, System.Text.Json.JsonSerializerOptions? options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task WriteAsJsonAsync(this Microsoft.AspNetCore.Http.HttpResponse response, object? value, System.Type type, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task WriteAsJsonAsync(this Microsoft.AspNetCore.Http.HttpResponse response, [System.Diagnostics.CodeAnalysis.AllowNullAttribute] TValue value, System.Text.Json.JsonSerializerOptions? options, string? contentType, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task WriteAsJsonAsync(this Microsoft.AspNetCore.Http.HttpResponse response, [System.Diagnostics.CodeAnalysis.AllowNullAttribute] TValue value, System.Text.Json.JsonSerializerOptions? options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task WriteAsJsonAsync(this Microsoft.AspNetCore.Http.HttpResponse response, [System.Diagnostics.CodeAnalysis.AllowNullAttribute] TValue value, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + } + public partial class JsonOptions + { + public JsonOptions() { } + public System.Text.Json.JsonSerializerOptions SerializerOptions { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } +} diff --git a/src/Http/Http.Extensions/src/HttpRequestExtensions.cs b/src/Http/Http.Extensions/src/HttpRequestExtensions.cs new file mode 100644 index 000000000000..769070b5b61c --- /dev/null +++ b/src/Http/Http.Extensions/src/HttpRequestExtensions.cs @@ -0,0 +1,54 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +#nullable enable + +namespace Microsoft.AspNetCore.Http +{ + public static class HttpRequestExtensions + { + /// + /// Checks the Content-Type header for JSON types. + /// + /// true if the Content-Type header represents a JSON content type; otherwise, false. + public static bool HasJsonContentType(this HttpRequest request) + { + return request.HasJsonContentType(out _); + } + + internal static bool HasJsonContentType(this HttpRequest request, out StringSegment charset) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (!MediaTypeHeaderValue.TryParse(request.ContentType, out var mt)) + { + charset = StringSegment.Empty; + return false; + } + + // Matches application/json + if (mt.MediaType.Equals(JsonConstants.JsonContentType, StringComparison.OrdinalIgnoreCase)) + { + charset = mt.Charset; + return true; + } + + // Matches +json, e.g. application/ld+json + if (mt.Suffix.Equals("json", StringComparison.OrdinalIgnoreCase)) + { + charset = mt.Charset; + return true; + } + + charset = StringSegment.Empty; + return false; + } + } +} diff --git a/src/Http/Http.Extensions/src/HttpRequestJsonExtensions.cs b/src/Http/Http.Extensions/src/HttpRequestJsonExtensions.cs new file mode 100644 index 000000000000..1e809ba6cea9 --- /dev/null +++ b/src/Http/Http.Extensions/src/HttpRequestJsonExtensions.cs @@ -0,0 +1,182 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +#nullable enable + +namespace Microsoft.AspNetCore.Http.Json +{ + public static class HttpRequestJsonExtensions + { + /// + /// Read JSON from the request and deserialize to the specified type. + /// If the request's content-type is not a known JSON type then an error will be thrown. + /// + /// The type of object to read. + /// The request to read from. + /// A used to cancel the operation. + /// The task object representing the asynchronous operation. + public static ValueTask ReadFromJsonAsync( + this HttpRequest request, + CancellationToken cancellationToken = default) + { + return request.ReadFromJsonAsync(options: null, cancellationToken); + } + + /// + /// Read JSON from the request and deserialize to the specified type. + /// If the request's content-type is not a known JSON type then an error will be thrown. + /// + /// The type of object to read. + /// The request to read from. + /// The serializer options use when deserializing the content. + /// A used to cancel the operation. + /// The task object representing the asynchronous operation. + public static async ValueTask ReadFromJsonAsync( + this HttpRequest request, + JsonSerializerOptions? options, + CancellationToken cancellationToken = default) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (!request.HasJsonContentType(out var charset)) + { + throw CreateContentTypeError(request); + } + + options ??= ResolveSerializerOptions(request.HttpContext); + + var encoding = GetEncodingFromCharset(charset); + var (inputStream, usesTranscodingStream) = GetInputStream(request.HttpContext, encoding); + + try + { + return await JsonSerializer.DeserializeAsync(inputStream, options, cancellationToken); + } + finally + { + if (usesTranscodingStream) + { + await inputStream.DisposeAsync(); + } + } + } + + /// + /// Read JSON from the request and deserialize to the specified type. + /// If the request's content-type is not a known JSON type then an error will be thrown. + /// + /// The request to read from. + /// The type of object to read. + /// A used to cancel the operation. + /// The task object representing the asynchronous operation. + public static ValueTask ReadFromJsonAsync( + this HttpRequest request, + Type type, + CancellationToken cancellationToken = default) + { + return request.ReadFromJsonAsync(type, options: null, cancellationToken); + } + + /// + /// Read JSON from the request and deserialize to the specified type. + /// If the request's content-type is not a known JSON type then an error will be thrown. + /// + /// The request to read from. + /// The type of object to read. + /// The serializer options use when deserializing the content. + /// A used to cancel the operation. + /// The task object representing the asynchronous operation. + public static async ValueTask ReadFromJsonAsync( + this HttpRequest request, + Type type, + JsonSerializerOptions? options, + CancellationToken cancellationToken = default) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (!request.HasJsonContentType(out var charset)) + { + throw CreateContentTypeError(request); + } + + options ??= ResolveSerializerOptions(request.HttpContext); + + var encoding = GetEncodingFromCharset(charset); + var (inputStream, usesTranscodingStream) = GetInputStream(request.HttpContext, encoding); + + try + { + return await JsonSerializer.DeserializeAsync(inputStream, type, options, cancellationToken); + } + finally + { + if (usesTranscodingStream) + { + await inputStream.DisposeAsync(); + } + } + } + + private static JsonSerializerOptions ResolveSerializerOptions(HttpContext httpContext) + { + // Attempt to resolve options from DI then fallback to default options + return httpContext.RequestServices?.GetService>()?.Value?.SerializerOptions ?? JsonOptions.DefaultSerializerOptions; + } + + private static InvalidOperationException CreateContentTypeError(HttpRequest request) + { + return new InvalidOperationException($"Unable to read the request as JSON because the request content type '{request.ContentType}' is not a known JSON content type."); + } + + private static (Stream inputStream, bool usesTranscodingStream) GetInputStream(HttpContext httpContext, Encoding? encoding) + { + if (encoding == null || encoding.CodePage == Encoding.UTF8.CodePage) + { + return (httpContext.Request.Body, false); + } + + var inputStream = Encoding.CreateTranscodingStream(httpContext.Request.Body, encoding, Encoding.UTF8, leaveOpen: true); + return (inputStream, true); + } + + private static Encoding? GetEncodingFromCharset(StringSegment charset) + { + if (charset.Equals("utf-8", StringComparison.OrdinalIgnoreCase)) + { + // This is an optimization for utf-8 that prevents the Substring caused by + // charset.Value + return Encoding.UTF8; + } + + try + { + // charset.Value might be an invalid encoding name as in charset=invalid. + return charset.HasValue ? Encoding.GetEncoding(charset.Value) : null; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Unable to read the request as JSON because the request content type charset '{charset}' is not a known encoding.", ex); + } + } + } +} diff --git a/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs b/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs new file mode 100644 index 000000000000..a3bfbb695814 --- /dev/null +++ b/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs @@ -0,0 +1,163 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +#nullable enable + +namespace Microsoft.AspNetCore.Http.Json +{ + public static partial class HttpResponseJsonExtensions + { + /// + /// Write the specified value as JSON to the response body. The response content-type will be set to + /// application/json; charset=utf-8 and the status code set to 200. + /// + /// The type of object to write. + /// The response to write JSON to. + /// The value to write as JSON. + /// A used to cancel the operation. + /// The task object representing the asynchronous operation. + public static Task WriteAsJsonAsync( + this HttpResponse response, + [AllowNull] TValue value, + CancellationToken cancellationToken = default) + { + return response.WriteAsJsonAsync(value, options: null, contentType: null, cancellationToken); + } + + /// + /// Write the specified value as JSON to the response body. The response content-type will be set to + /// application/json; charset=utf-8 and the status code set to 200. + /// + /// The type of object to write. + /// The response to write JSON to. + /// The value to write as JSON. + /// The serializer options use when serializing the value. + /// A used to cancel the operation. + /// The task object representing the asynchronous operation. + public static Task WriteAsJsonAsync( + this HttpResponse response, + [AllowNull] TValue value, + JsonSerializerOptions? options, + CancellationToken cancellationToken = default) + { + return response.WriteAsJsonAsync(value, options, contentType: null, cancellationToken); + } + + /// + /// Write the specified value as JSON to the response body. The response content-type will be set to + /// the specified content-type and the status code set to 200. + /// + /// The type of object to write. + /// The response to write JSON to. + /// The value to write as JSON. + /// The serializer options use when serializing the value. + /// The content-type to set on the response. + /// A used to cancel the operation. + /// The task object representing the asynchronous operation. + public static Task WriteAsJsonAsync( + this HttpResponse response, + [AllowNull] TValue value, + JsonSerializerOptions? options, + string? contentType, + CancellationToken cancellationToken = default) + { + if (response == null) + { + throw new ArgumentNullException(nameof(response)); + } + + options ??= ResolveSerializerOptions(response.HttpContext); + + response.ContentType = contentType ?? JsonConstants.JsonContentTypeWithCharset; + response.StatusCode = StatusCodes.Status200OK; + return JsonSerializer.SerializeAsync(response.Body, value!, options, cancellationToken); + } + + /// + /// Write the specified value as JSON to the response body. The response content-type will be set to + /// application/json; charset=utf-8 and the status code set to 200. + /// + /// The response to write JSON to. + /// The value to write as JSON. + /// The type of object to write. + /// A used to cancel the operation. + /// The task object representing the asynchronous operation. + public static Task WriteAsJsonAsync( + this HttpResponse response, + object? value, + Type type, + CancellationToken cancellationToken = default) + { + return response.WriteAsJsonAsync(value, type, options: null, contentType: null, cancellationToken); + } + + /// + /// Write the specified value as JSON to the response body. The response content-type will be set to + /// application/json; charset=utf-8 and the status code set to 200. + /// + /// The response to write JSON to. + /// The value to write as JSON. + /// The type of object to write. + /// The serializer options use when serializing the value. + /// A used to cancel the operation. + /// The task object representing the asynchronous operation. + public static Task WriteAsJsonAsync( + this HttpResponse response, + object? value, + Type type, + JsonSerializerOptions? options, + CancellationToken cancellationToken = default) + { + return response.WriteAsJsonAsync(value, type, options, contentType: null, cancellationToken); + } + + /// + /// Write the specified value as JSON to the response body. The response content-type will be set to + /// the specified content-type and the status code set to 200. + /// + /// The response to write JSON to. + /// The value to write as JSON. + /// The type of object to write. + /// The serializer options use when serializing the value. + /// The content-type to set on the response. + /// A used to cancel the operation. + /// The task object representing the asynchronous operation. + public static Task WriteAsJsonAsync( + this HttpResponse response, + object? value, + Type type, + JsonSerializerOptions? options, + string? contentType, + CancellationToken cancellationToken = default) + { + if (response == null) + { + throw new ArgumentNullException(nameof(response)); + } + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + options ??= ResolveSerializerOptions(response.HttpContext); + + response.ContentType = contentType ?? JsonConstants.JsonContentTypeWithCharset; + response.StatusCode = StatusCodes.Status200OK; + return JsonSerializer.SerializeAsync(response.Body, value, type, options, cancellationToken); + } + + private static JsonSerializerOptions ResolveSerializerOptions(HttpContext httpContext) + { + // Attempt to resolve options from DI then fallback to default options + return httpContext.RequestServices?.GetService>()?.Value?.SerializerOptions ?? JsonOptions.DefaultSerializerOptions; + } + } +} diff --git a/src/Http/Http.Extensions/src/JsonConstants.cs b/src/Http/Http.Extensions/src/JsonConstants.cs new file mode 100644 index 000000000000..a905119bff88 --- /dev/null +++ b/src/Http/Http.Extensions/src/JsonConstants.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http +{ + internal static class JsonConstants + { + public const string JsonContentType = "application/json"; + public const string JsonContentTypeWithCharset = "application/json; charset=utf-8"; + } +} diff --git a/src/Http/Http.Extensions/src/JsonOptions.cs b/src/Http/Http.Extensions/src/JsonOptions.cs new file mode 100644 index 000000000000..c06f67291222 --- /dev/null +++ b/src/Http/Http.Extensions/src/JsonOptions.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Text.Encodings.Web; +using System.Text.Json; + +#nullable enable + +namespace Microsoft.AspNetCore.Http.Json +{ + public class JsonOptions + { + internal static readonly JsonSerializerOptions DefaultSerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + // Web defaults don't use the relex JSON escaping encoder. + // + // Because these options are for producing content that is written directly to the request + // (and not embedded in an HTML page for example), we can use UnsafeRelaxedJsonEscaping. + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + // Use a copy so the defaults are not modified. + public JsonSerializerOptions SerializerOptions { get; } = new JsonSerializerOptions(DefaultSerializerOptions); + } +} diff --git a/src/Http/Http.Extensions/src/Properties/AssemblyInfo.cs b/src/Http/Http.Extensions/src/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..f5c2638e6462 --- /dev/null +++ b/src/Http/Http.Extensions/src/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Http.Extensions.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Http/Http.Extensions/test/HttpRequestExtensionsTests.cs b/src/Http/Http.Extensions/test/HttpRequestExtensionsTests.cs new file mode 100644 index 000000000000..99866fb5d1df --- /dev/null +++ b/src/Http/Http.Extensions/test/HttpRequestExtensionsTests.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +#nullable enable + +namespace Microsoft.AspNetCore.Http.Extensions.Tests +{ + public class HttpRequestExtensionsTests + { + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData("application/xml", false)] + [InlineData("text/json", false)] + [InlineData("text/json; charset=utf-8", false)] + [InlineData("application/json", true)] + [InlineData("application/json; charset=utf-8", true)] + [InlineData("application/ld+json", true)] + [InlineData("APPLICATION/JSON", true)] + [InlineData("APPLICATION/JSON; CHARSET=UTF-8", true)] + [InlineData("APPLICATION/LD+JSON", true)] + public void HasJsonContentType(string contentType, bool hasJsonContentType) + { + var request = new DefaultHttpContext().Request; + request.ContentType = contentType; + + Assert.Equal(hasJsonContentType, request.HasJsonContentType()); + } + } +} diff --git a/src/Http/Http.Extensions/test/HttpRequestJsonExtensionsTests.cs b/src/Http/Http.Extensions/test/HttpRequestJsonExtensionsTests.cs new file mode 100644 index 000000000000..a98a5102c452 --- /dev/null +++ b/src/Http/Http.Extensions/test/HttpRequestJsonExtensionsTests.cs @@ -0,0 +1,215 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http.Json; +using Xunit; + +#nullable enable + +namespace Microsoft.AspNetCore.Http.Extensions.Tests +{ + public class HttpRequestJsonExtensionsTests + { + [Fact] + public async Task ReadFromJsonAsyncGeneric_NonJsonContentType_ThrowError() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.ContentType = "text/json"; + + // Act + var ex = await Assert.ThrowsAsync(async () => await context.Request.ReadFromJsonAsync()); + + // Assert + var exceptedMessage = $"Unable to read the request as JSON because the request content type 'text/json' is not a known JSON content type."; + Assert.Equal(exceptedMessage, ex.Message); + } + + [Fact] + public async Task ReadFromJsonAsyncGeneric_NoBodyContent_ThrowError() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.ContentType = "application/json"; + + // Act + var ex = await Assert.ThrowsAsync(async () => await context.Request.ReadFromJsonAsync()); + + // Assert + var exceptedMessage = $"The input does not contain any JSON tokens. Expected the input to start with a valid JSON token, when isFinalBlock is true. Path: $ | LineNumber: 0 | BytePositionInLine: 0."; + Assert.Equal(exceptedMessage, ex.Message); + } + + [Fact] + public async Task ReadFromJsonAsyncGeneric_ValidBodyContent_ReturnValue() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.ContentType = "application/json"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("1")); + + // Act + var result = await context.Request.ReadFromJsonAsync(); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public async Task ReadFromJsonAsyncGeneric_WithOptions_ReturnValue() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.ContentType = "application/json"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("[1,2,]")); + + var options = new JsonSerializerOptions(); + options.AllowTrailingCommas = true; + + // Act + var result = await context.Request.ReadFromJsonAsync>(options); + + // Assert + Assert.Collection(result, + i => Assert.Equal(1, i), + i => Assert.Equal(2, i)); + } + + [Fact] + public async Task ReadFromJsonAsyncGeneric_Utf8Encoding_ReturnValue() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.ContentType = "application/json; charset=utf-8"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("[1,2]")); + + // Act + var result = await context.Request.ReadFromJsonAsync>(); + + // Assert + Assert.Collection(result, + i => Assert.Equal(1, i), + i => Assert.Equal(2, i)); + } + + [Fact] + public async Task ReadFromJsonAsyncGeneric_Utf16Encoding_ReturnValue() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.ContentType = "application/json; charset=utf-16"; + context.Request.Body = new MemoryStream(Encoding.Unicode.GetBytes(@"{""name"": ""激光這兩個字是甚麼意思""}")); + + // Act + var result = await context.Request.ReadFromJsonAsync>(); + + // Assert + Assert.Equal("激光這兩個字是甚麼意思", result["name"]); + } + + [Fact] + public async Task ReadFromJsonAsyncGeneric_WithCancellationToken_CancellationRaised() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.ContentType = "application /json"; + context.Request.Body = new TestStream(); + + var cts = new CancellationTokenSource(); + + // Act + var readTask = context.Request.ReadFromJsonAsync>(cts.Token); + Assert.False(readTask.IsCompleted); + + cts.Cancel(); + + // Assert + await Assert.ThrowsAsync(async () => await readTask); + } + + [Fact] + public async Task ReadFromJsonAsyncGeneric_InvalidEncoding_ThrowError() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.ContentType = "application/json; charset=invalid"; + + // Act + var ex = await Assert.ThrowsAsync(async () => await context.Request.ReadFromJsonAsync()); + + // Assert + Assert.Equal("Unable to read the request as JSON because the request content type charset 'invalid' is not a known encoding.", ex.Message); + } + + [Fact] + public async Task ReadFromJsonAsync_ValidBodyContent_ReturnValue() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.ContentType = "application/json"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("1")); + + // Act + var result = (int?)await context.Request.ReadFromJsonAsync(typeof(int)); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public async Task ReadFromJsonAsync_Utf16Encoding_ReturnValue() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.ContentType = "application/json; charset=utf-16"; + context.Request.Body = new MemoryStream(Encoding.Unicode.GetBytes(@"{""name"": ""激光這兩個字是甚麼意思""}")); + + // Act + var result = (Dictionary?)await context.Request.ReadFromJsonAsync(typeof(Dictionary)); + + // Assert + Assert.Equal("激光這兩個字是甚麼意思", result!["name"]); + } + + [Fact] + public async Task ReadFromJsonAsync_InvalidEncoding_ThrowError() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.ContentType = "application/json; charset=invalid"; + + // Act + var ex = await Assert.ThrowsAsync(async () => await context.Request.ReadFromJsonAsync(typeof(object))); + + // Assert + Assert.Equal("Unable to read the request as JSON because the request content type charset 'invalid' is not a known encoding.", ex.Message); + } + + [Fact] + public async Task ReadFromJsonAsync_WithOptions_ReturnValue() + { + // Arrange + var context = new DefaultHttpContext(); + context.Request.ContentType = "application/json"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("[1,2,]")); + + var options = new JsonSerializerOptions(); + options.AllowTrailingCommas = true; + + // Act + var result = (List?)await context.Request.ReadFromJsonAsync(typeof(List), options); + + // Assert + Assert.Collection(result, + i => Assert.Equal(1, i), + i => Assert.Equal(2, i)); + } + } +} diff --git a/src/Http/Http.Extensions/test/HttpResponseJsonExtensionsTests.cs b/src/Http/Http.Extensions/test/HttpResponseJsonExtensionsTests.cs new file mode 100644 index 000000000000..74add9b9e1ec --- /dev/null +++ b/src/Http/Http.Extensions/test/HttpResponseJsonExtensionsTests.cs @@ -0,0 +1,279 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http.Json; +using Xunit; + +#nullable enable + +namespace Microsoft.AspNetCore.Http.Extensions.Tests +{ + public class HttpResponseJsonExtensionsTests + { + [Fact] + public async Task WriteAsJsonAsyncGeneric_SimpleValue_JsonResponse() + { + // Arrange + var body = new MemoryStream(); + var context = new DefaultHttpContext(); + context.Response.Body = body; + + // Act + await context.Response.WriteAsJsonAsync(1); + + // Assert + Assert.Equal(JsonConstants.JsonContentTypeWithCharset, context.Response.ContentType); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + + var data = body.ToArray(); + Assert.Collection(data, b => Assert.Equal((byte)'1', b)); + } + + [Fact] + public async Task WriteAsJsonAsyncGeneric_NullValue_JsonResponse() + { + // Arrange + var body = new MemoryStream(); + var context = new DefaultHttpContext(); + context.Response.Body = body; + + // Act + await context.Response.WriteAsJsonAsync(value: null); + + // Assert + Assert.Equal(JsonConstants.JsonContentTypeWithCharset, context.Response.ContentType); + + var data = Encoding.UTF8.GetString(body.ToArray()); + Assert.Equal("null", data); + } + + [Fact] + public async Task WriteAsJsonAsyncGeneric_WithOptions_JsonResponse() + { + // Arrange + var body = new MemoryStream(); + var context = new DefaultHttpContext(); + context.Response.Body = body; + + // Act + var options = new JsonSerializerOptions(); + options.Converters.Add(new IntegerConverter()); + await context.Response.WriteAsJsonAsync(new int[] { 1, 2, 3 }, options); + + // Assert + Assert.Equal(JsonConstants.JsonContentTypeWithCharset, context.Response.ContentType); + + var data = Encoding.UTF8.GetString(body.ToArray()); + Assert.Equal("[false,true,false]", data); + } + + private class IntegerConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + { + writer.WriteBooleanValue(value % 2 == 0); + } + } + + [Fact] + public async Task WriteAsJsonAsyncGeneric_WithContentType_JsonResponseWithCustomContentType() + { + // Arrange + var body = new MemoryStream(); + var context = new DefaultHttpContext(); + context.Response.Body = body; + + // Act + await context.Response.WriteAsJsonAsync(1, options: null, contentType: "application/custom-type"); + + // Assert + Assert.Equal("application/custom-type", context.Response.ContentType); + } + + [Fact] + public async Task WriteAsJsonAsyncGeneric_WithCancellationToken_CancellationRaised() + { + // Arrange + var context = new DefaultHttpContext(); + context.Response.Body = new TestStream(); + + var cts = new CancellationTokenSource(); + + // Act + var writeTask = context.Response.WriteAsJsonAsync(1, cts.Token); + Assert.False(writeTask.IsCompleted); + + cts.Cancel(); + + // Assert + await Assert.ThrowsAsync(async () => await writeTask); + } + + [Fact] + public async Task WriteAsJsonAsyncGeneric_ObjectWithStrings_CamcelCaseAndNotEscaped() + { + // Arrange + var body = new MemoryStream(); + var context = new DefaultHttpContext(); + context.Response.Body = body; + var value = new TestObject + { + StringProperty = "激光這兩個字是甚麼意思" + }; + + // Act + await context.Response.WriteAsJsonAsync(value); + + // Assert + var data = Encoding.UTF8.GetString(body.ToArray()); + Assert.Equal(@"{""stringProperty"":""激光這兩個字是甚麼意思""}", data); + } + + [Fact] + public async Task WriteAsJsonAsync_SimpleValue_JsonResponse() + { + // Arrange + var body = new MemoryStream(); + var context = new DefaultHttpContext(); + context.Response.Body = body; + + // Act + await context.Response.WriteAsJsonAsync(1, typeof(int)); + + // Assert + Assert.Equal(JsonConstants.JsonContentTypeWithCharset, context.Response.ContentType); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + + var data = body.ToArray(); + Assert.Collection(data, b => Assert.Equal((byte)'1', b)); + } + + [Fact] + public async Task WriteAsJsonAsync_NullValue_JsonResponse() + { + // Arrange + var body = new MemoryStream(); + var context = new DefaultHttpContext(); + context.Response.Body = body; + + // Act + await context.Response.WriteAsJsonAsync(value: null, typeof(int?)); + + // Assert + Assert.Equal(JsonConstants.JsonContentTypeWithCharset, context.Response.ContentType); + + var data = Encoding.UTF8.GetString(body.ToArray()); + Assert.Equal("null", data); + } + + [Fact] + public async Task WriteAsJsonAsync_NullType_ThrowsArgumentNullException() + { + // Arrange + var body = new MemoryStream(); + var context = new DefaultHttpContext(); + context.Response.Body = body; + + // Act & Assert + await Assert.ThrowsAsync(async () => await context.Response.WriteAsJsonAsync(value: null, type: null!)); + } + + [Fact] + public async Task WriteAsJsonAsync_NullResponse_ThrowsArgumentNullException() + { + // Arrange + var body = new MemoryStream(); + var context = new DefaultHttpContext(); + context.Response.Body = body; + + // Act & Assert + await Assert.ThrowsAsync(async () => await HttpResponseJsonExtensions.WriteAsJsonAsync(response: null!, value: null, typeof(int?))); + } + + [Fact] + public async Task WriteAsJsonAsync_ObjectWithStrings_CamcelCaseAndNotEscaped() + { + // Arrange + var body = new MemoryStream(); + var context = new DefaultHttpContext(); + context.Response.Body = body; + var value = new TestObject + { + StringProperty = "激光這兩個字是甚麼意思" + }; + + // Act + await context.Response.WriteAsJsonAsync(value, typeof(TestObject)); + + // Assert + var data = Encoding.UTF8.GetString(body.ToArray()); + Assert.Equal(@"{""stringProperty"":""激光這兩個字是甚麼意思""}", data); + } + + public class TestObject + { + public string? StringProperty { get; set; } + } + + private class TestStream : Stream + { + public override bool CanRead { get; } + public override bool CanSeek { get; } + public override bool CanWrite { get; } + public override long Length { get; } + public override long Position { get; set; } + + public override void Flush() + { + throw new NotImplementedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotImplementedException(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotImplementedException(); + } + + public override void SetLength(long value) + { + throw new NotImplementedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotImplementedException(); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var tcs = new TaskCompletionSource(); + cancellationToken.Register(s => ((TaskCompletionSource)s!).SetCanceled(), tcs); + return new ValueTask(tcs.Task); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + var tcs = new TaskCompletionSource(); + cancellationToken.Register(s => ((TaskCompletionSource)s!).SetCanceled(), tcs); + return new ValueTask(tcs.Task); + } + } + } +} diff --git a/src/Http/Http.Extensions/test/SendFileResponseExtensionsTests.cs b/src/Http/Http.Extensions/test/SendFileResponseExtensionsTests.cs index 134b882b9948..5f5a6341f818 100644 --- a/src/Http/Http.Extensions/test/SendFileResponseExtensionsTests.cs +++ b/src/Http/Http.Extensions/test/SendFileResponseExtensionsTests.cs @@ -1,4 +1,5 @@ -// Copyright (c) .NET Foundation. All rights reserved. See License.txt in the project root for license information. +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.IO; using System.IO.Pipelines; diff --git a/src/Http/Http.Extensions/test/TestStream.cs b/src/Http/Http.Extensions/test/TestStream.cs new file mode 100644 index 000000000000..fb9b4e52a7a8 --- /dev/null +++ b/src/Http/Http.Extensions/test/TestStream.cs @@ -0,0 +1,58 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Http.Extensions.Tests +{ + public class TestStream : Stream + { + public override bool CanRead { get; } + public override bool CanSeek { get; } + public override bool CanWrite { get; } + public override long Length { get; } + public override long Position { get; set; } + + public override void Flush() + { + throw new NotImplementedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotImplementedException(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotImplementedException(); + } + + public override void SetLength(long value) + { + throw new NotImplementedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotImplementedException(); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var tcs = new TaskCompletionSource(); + cancellationToken.Register(s => ((TaskCompletionSource)s).SetCanceled(), tcs); + return new ValueTask(tcs.Task); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + var tcs = new TaskCompletionSource(); + cancellationToken.Register(s => ((TaskCompletionSource)s).SetCanceled(), tcs); + return new ValueTask(tcs.Task); + } + } +}