Skip to content

Commit e4c1c51

Browse files
committed
Adding TextInputFormatter which deserializes async stream from NDJSON
1 parent 2bd5221 commit e4c1c51

File tree

13 files changed

+450
-15
lines changed

13 files changed

+450
-15
lines changed

src/Ndjson.AsyncStreams.AspNetCore.Mvc.NewtonsoftJson/DependencyInjection/NewtonsoftNdjsonMvcBuilderExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System;
2+
using Microsoft.Extensions.Options;
23
using Microsoft.Extensions.DependencyInjection.Extensions;
4+
using Microsoft.AspNetCore.Mvc;
35
using Ndjson.AsyncStreams.AspNetCore.Mvc;
46
using Ndjson.AsyncStreams.AspNetCore.Mvc.NewtonsoftJson.Internals;
57

@@ -23,6 +25,7 @@ public static IMvcBuilder AddNewtonsoftNdjson(this IMvcBuilder builder)
2325
}
2426

2527
builder.Services.TryAddSingleton<INdjsonWriterFactory, NewtonsoftNdjsonWriterFactory>();
28+
builder.Services.AddSingleton<IConfigureOptions<MvcOptions>, NewtonsoftNdjsonMvcOptionsSetup>();
2629

2730
return builder;
2831
}

src/Ndjson.AsyncStreams.AspNetCore.Mvc.NewtonsoftJson/DependencyInjection/NewtonsoftNdjsonMvcCoreBuilderExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System;
2+
using Microsoft.Extensions.Options;
23
using Microsoft.Extensions.DependencyInjection.Extensions;
4+
using Microsoft.AspNetCore.Mvc;
35
using Ndjson.AsyncStreams.AspNetCore.Mvc;
46
using Ndjson.AsyncStreams.AspNetCore.Mvc.NewtonsoftJson.Internals;
57

@@ -23,6 +25,7 @@ public static IMvcCoreBuilder AddNewtonsoftNdjson(this IMvcCoreBuilder builder)
2325
}
2426

2527
builder.Services.TryAddSingleton<INdjsonWriterFactory, NewtonsoftNdjsonWriterFactory>();
28+
builder.Services.AddSingleton<IConfigureOptions<MvcOptions>, NewtonsoftNdjsonMvcOptionsSetup>();
2629

2730
return builder;
2831
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.Extensions.Logging;
4+
using Microsoft.Extensions.Options;
5+
using Ndjson.AsyncStreams.AspNetCore.Mvc.NewtonsoftJson.Formatters;
6+
7+
namespace Microsoft.Extensions.DependencyInjection
8+
{
9+
internal class NewtonsoftNdjsonMvcOptionsSetup : IConfigureOptions<MvcOptions>
10+
{
11+
private readonly IOptions<MvcNewtonsoftJsonOptions> _jsonOptions;
12+
private readonly ILogger<NewtonsoftNdjsonInputFormatter> _logger;
13+
14+
public NewtonsoftNdjsonMvcOptionsSetup(IOptions<MvcNewtonsoftJsonOptions> jsonOptions, ILogger<NewtonsoftNdjsonInputFormatter> logger)
15+
{
16+
_jsonOptions = jsonOptions ?? throw new ArgumentNullException(nameof(jsonOptions));
17+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
18+
}
19+
20+
public void Configure(MvcOptions options)
21+
{
22+
options.InputFormatters.Add(new NewtonsoftNdjsonInputFormatter(_jsonOptions.Value.SerializerSettings, _logger));
23+
}
24+
}
25+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
using System;
2+
using System.IO;
3+
using System.Text;
4+
using System.Threading.Tasks;
5+
using System.Collections.Generic;
6+
using Microsoft.Net.Http.Headers;
7+
using Microsoft.Extensions.Logging;
8+
using Microsoft.AspNetCore.Mvc.Formatters;
9+
using Newtonsoft.Json;
10+
11+
namespace Ndjson.AsyncStreams.AspNetCore.Mvc.NewtonsoftJson.Formatters
12+
{
13+
/// <summary>
14+
/// A <see cref="TextInputFormatter"/> for async stream incoming as NDJSON content that uses <see cref="JsonSerializer"/>.
15+
/// </summary>
16+
public class NewtonsoftNdjsonInputFormatter : TextInputFormatter, IInputFormatterExceptionPolicy
17+
{
18+
private interface IAsyncEnumerableModelReader
19+
{
20+
object ReadModel(Stream inputStream, JsonSerializerSettings serializerSettings);
21+
}
22+
23+
private class AsyncEnumerableModelReader<T> : IAsyncEnumerableModelReader
24+
{
25+
public object ReadModel(Stream inputStream, JsonSerializerSettings serializerSettings)
26+
{
27+
return ReadModelInternal(inputStream, serializerSettings);
28+
}
29+
30+
private static async IAsyncEnumerable<T> ReadModelInternal(Stream inputStream, JsonSerializerSettings serializerSettings)
31+
{
32+
using StreamReader inputStreamReader = new(inputStream);
33+
34+
string? valueUtf8Json = await inputStreamReader.ReadLineAsync();
35+
while (!(valueUtf8Json is null))
36+
{
37+
yield return JsonConvert.DeserializeObject<T>(valueUtf8Json, serializerSettings);
38+
39+
valueUtf8Json = await inputStreamReader.ReadLineAsync();
40+
}
41+
}
42+
}
43+
44+
private static readonly Type _asyncEnumerableType = typeof(IAsyncEnumerable<>);
45+
46+
private readonly ILogger<NewtonsoftNdjsonInputFormatter> _logger;
47+
48+
/// <inheritdoc />
49+
public InputFormatterExceptionPolicy ExceptionPolicy => InputFormatterExceptionPolicy.MalformedInputExceptions;
50+
51+
/// <summary>
52+
/// Gets the <see cref="JsonSerializerSettings"/> used to configure the <see cref="JsonSerializer"/>.
53+
/// </summary>
54+
/// <remarks>
55+
/// A single instance of <see cref="NewtonsoftNdjsonInputFormatter"/> is used for all JSON formatting. Any changes to the options will affect all input formatting.
56+
/// </remarks>
57+
public JsonSerializerSettings SerializerSettings { get; }
58+
59+
/// <summary>
60+
/// Initializes a new instance of <see cref="NewtonsoftNdjsonInputFormatter"/>.
61+
/// </summary>
62+
/// <param name="serializerSettings">The <see cref="JsonSerializerSettings"/>.</param>
63+
/// <param name="logger">The <see cref="ILogger"/>.</param>
64+
public NewtonsoftNdjsonInputFormatter(JsonSerializerSettings serializerSettings, ILogger<NewtonsoftNdjsonInputFormatter> logger)
65+
{
66+
SerializerSettings = serializerSettings ?? throw new ArgumentNullException(nameof(serializerSettings));
67+
68+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
69+
70+
SupportedEncodings.Add(UTF8EncodingWithoutBOM);
71+
72+
SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/x-ndjson"));
73+
}
74+
75+
/// <inheritdoc />
76+
protected override bool CanReadType(Type type)
77+
{
78+
return type.IsGenericType && (type.GetGenericTypeDefinition() == _asyncEnumerableType);
79+
}
80+
81+
/// <inheritdoc />
82+
public override Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding)
83+
{
84+
if (context is null)
85+
{
86+
throw new ArgumentNullException(nameof(context));
87+
}
88+
89+
if (encoding is null)
90+
{
91+
throw new ArgumentNullException(nameof(encoding));
92+
}
93+
94+
Type modelReaderType = typeof(AsyncEnumerableModelReader<>).MakeGenericType(context.ModelType.GetGenericArguments()[0]);
95+
IAsyncEnumerableModelReader? modelReader = (IAsyncEnumerableModelReader?)Activator.CreateInstance(modelReaderType);
96+
97+
if (modelReader is null)
98+
{
99+
string errorMessage = $"Couldn't create an instance of {modelReaderType.Name} for deserializing incoming async stream.";
100+
101+
_logger.LogDebug(errorMessage);
102+
context.ModelState.TryAddModelError(String.Empty, errorMessage);
103+
104+
return Task.FromResult(InputFormatterResult.Failure());
105+
}
106+
107+
return Task.FromResult(InputFormatterResult.Success(modelReader.ReadModel(context.HttpContext.Request.Body, SerializerSettings)));
108+
}
109+
}
110+
}

src/Ndjson.AsyncStreams.AspNetCore.Mvc/DependencyInjection/NdjsonMvcBuilderExtensions.cs renamed to src/Ndjson.AsyncStreams.AspNetCore.Mvc/DependencyInjection/SystemTextNdjsonMvcBuilderExtensions.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.Extensions.Options;
24
using Microsoft.Extensions.DependencyInjection.Extensions;
35
using Ndjson.AsyncStreams.AspNetCore.Mvc;
46
using Ndjson.AsyncStreams.AspNetCore.Mvc.Internals;
@@ -8,7 +10,7 @@ namespace Microsoft.Extensions.DependencyInjection
810
/// <summary>
911
/// Extensions methods for configuring MVC via an <see cref="IMvcBuilder"/>.
1012
/// </summary>
11-
public static class NdjsonMvcBuilderExtensions
13+
public static class SystemTextNdjsonMvcBuilderExtensions
1214
{
1315
/// <summary>
1416
/// Configures NDJSON support for async streams.
@@ -22,7 +24,8 @@ public static IMvcBuilder AddNdjson(this IMvcBuilder builder)
2224
throw new ArgumentNullException(nameof(builder));
2325
}
2426

25-
builder.Services.TryAddSingleton<INdjsonWriterFactory, NdjsonWriterFactory>();
27+
builder.Services.TryAddSingleton<INdjsonWriterFactory, SystemTextNdjsonWriterFactory>();
28+
builder.Services.AddSingleton<IConfigureOptions<MvcOptions>, SystemTextNdjsonMvcOptionsSetup>();
2629

2730
return builder;
2831
}

src/Ndjson.AsyncStreams.AspNetCore.Mvc/DependencyInjection/NdjsonMvcCoreBuilderExtensions.cs renamed to src/Ndjson.AsyncStreams.AspNetCore.Mvc/DependencyInjection/SystemTextNdjsonMvcCoreBuilderExtensions.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.Extensions.Options;
24
using Microsoft.Extensions.DependencyInjection.Extensions;
35
using Ndjson.AsyncStreams.AspNetCore.Mvc;
46
using Ndjson.AsyncStreams.AspNetCore.Mvc.Internals;
@@ -8,7 +10,7 @@ namespace Microsoft.Extensions.DependencyInjection
810
/// <summary>
911
/// Extensions methods for configuring MVC via an <see cref="IMvcCoreBuilder"/>.
1012
/// </summary>
11-
public static class NdjsonMvcCoreBuilderExtensions
13+
public static class SystemTextNdjsonMvcCoreBuilderExtensions
1214
{
1315
/// <summary>
1416
/// Configures NDJSON support for async streams.
@@ -22,7 +24,8 @@ public static IMvcCoreBuilder AddNdjson(this IMvcCoreBuilder builder)
2224
throw new ArgumentNullException(nameof(builder));
2325
}
2426

25-
builder.Services.TryAddSingleton<INdjsonWriterFactory, NdjsonWriterFactory>();
27+
builder.Services.TryAddSingleton<INdjsonWriterFactory, SystemTextNdjsonWriterFactory>();
28+
builder.Services.AddSingleton<IConfigureOptions<MvcOptions>, SystemTextNdjsonMvcOptionsSetup>();
2629

2730
return builder;
2831
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.Extensions.Logging;
4+
using Microsoft.Extensions.Options;
5+
using Ndjson.AsyncStreams.AspNetCore.Mvc.Formatters;
6+
7+
namespace Microsoft.Extensions.DependencyInjection
8+
{
9+
internal class SystemTextNdjsonMvcOptionsSetup : IConfigureOptions<MvcOptions>
10+
{
11+
private readonly IOptions<JsonOptions>? _jsonOptions;
12+
private readonly ILogger<SystemTextNdjsonInputFormatter> _logger;
13+
14+
public SystemTextNdjsonMvcOptionsSetup(IOptions<JsonOptions>? jsonOptions, ILogger<SystemTextNdjsonInputFormatter> logger)
15+
{
16+
_jsonOptions = jsonOptions;
17+
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); ;
18+
}
19+
20+
public void Configure(MvcOptions options)
21+
{
22+
options.InputFormatters.Add(new SystemTextNdjsonInputFormatter(_jsonOptions?.Value, _logger));
23+
}
24+
}
25+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using System;
2+
using System.IO;
3+
using System.Text;
4+
using System.Text.Json;
5+
using System.Threading.Tasks;
6+
using System.Collections.Generic;
7+
using Microsoft.Net.Http.Headers;
8+
using Microsoft.Extensions.Logging;
9+
using Microsoft.AspNetCore.Mvc;
10+
using Microsoft.AspNetCore.Mvc.Formatters;
11+
12+
namespace Ndjson.AsyncStreams.AspNetCore.Mvc.Formatters
13+
{
14+
/// <summary>
15+
/// A <see cref="TextInputFormatter"/> for async stream incoming as NDJSON content that uses <see cref="JsonSerializer"/>.
16+
/// </summary>
17+
public class SystemTextNdjsonInputFormatter : TextInputFormatter, IInputFormatterExceptionPolicy
18+
{
19+
private interface IAsyncEnumerableModelReader
20+
{
21+
object ReadModel(Stream inputStream, JsonSerializerOptions serializerOptions);
22+
}
23+
24+
private class AsyncEnumerableModelReader<T> : IAsyncEnumerableModelReader
25+
{
26+
public object ReadModel(Stream inputStream, JsonSerializerOptions serializerOptions)
27+
{
28+
return ReadModelInternal(inputStream, serializerOptions);
29+
}
30+
31+
private static async IAsyncEnumerable<T> ReadModelInternal(Stream inputStream, JsonSerializerOptions serializerOptions)
32+
{
33+
using StreamReader inputStreamReader = new(inputStream);
34+
35+
string? valueUtf8Json = await inputStreamReader.ReadLineAsync();
36+
while (!(valueUtf8Json is null))
37+
{
38+
yield return JsonSerializer.Deserialize<T>(valueUtf8Json, serializerOptions);
39+
40+
valueUtf8Json = await inputStreamReader.ReadLineAsync();
41+
}
42+
}
43+
}
44+
45+
#if NETCOREAPP3_1
46+
private static readonly JsonSerializerOptions _defaultJsonSerializerOptions = new()
47+
{
48+
PropertyNameCaseInsensitive = true,
49+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
50+
};
51+
#endif
52+
53+
#if NET5_0
54+
private static readonly JsonSerializerOptions _defaultJsonSerializerOptions = new(JsonSerializerDefaults.Web);
55+
#endif
56+
57+
private static readonly Type _asyncEnumerableType = typeof(IAsyncEnumerable<>);
58+
59+
private readonly ILogger<SystemTextNdjsonInputFormatter> _logger;
60+
61+
/// <inheritdoc />
62+
public InputFormatterExceptionPolicy ExceptionPolicy => InputFormatterExceptionPolicy.MalformedInputExceptions;
63+
64+
/// <summary>
65+
/// Gets the <see cref="JsonSerializerOptions"/> used to configure the <see cref="JsonSerializer"/>.
66+
/// </summary>
67+
/// <remarks>
68+
/// A single instance of <see cref="SystemTextNdjsonInputFormatter"/> is used for all JSON formatting. Any changes to the options will affect all input formatting.
69+
/// </remarks>
70+
public JsonSerializerOptions SerializerOptions { get; }
71+
72+
/// <summary>
73+
/// Initializes a new instance of <see cref="SystemTextNdjsonInputFormatter"/>.
74+
/// </summary>
75+
/// <param name="options">The <see cref="JsonOptions"/>.</param>
76+
/// <param name="logger">The <see cref="ILogger"/>.</param>
77+
public SystemTextNdjsonInputFormatter(JsonOptions? options, ILogger<SystemTextNdjsonInputFormatter> logger)
78+
{
79+
SerializerOptions = options?.JsonSerializerOptions ?? _defaultJsonSerializerOptions;
80+
81+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
82+
83+
SupportedEncodings.Add(UTF8EncodingWithoutBOM);
84+
85+
SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/x-ndjson"));
86+
}
87+
88+
/// <inheritdoc />
89+
protected override bool CanReadType(Type type)
90+
{
91+
return type.IsGenericType && (type.GetGenericTypeDefinition() == _asyncEnumerableType);
92+
}
93+
94+
/// <inheritdoc />
95+
public override Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding)
96+
{
97+
if (context is null)
98+
{
99+
throw new ArgumentNullException(nameof(context));
100+
}
101+
102+
if (encoding is null)
103+
{
104+
throw new ArgumentNullException(nameof(encoding));
105+
}
106+
107+
Type modelReaderType = typeof(AsyncEnumerableModelReader<>).MakeGenericType(context.ModelType.GetGenericArguments()[0]);
108+
IAsyncEnumerableModelReader? modelReader = (IAsyncEnumerableModelReader?)Activator.CreateInstance(modelReaderType);
109+
110+
if (modelReader is null)
111+
{
112+
string errorMessage = $"Couldn't create an instance of {modelReaderType.Name} for deserializing incoming async stream.";
113+
114+
_logger.LogDebug(errorMessage);
115+
context.ModelState.TryAddModelError(String.Empty, errorMessage);
116+
117+
return Task.FromResult(InputFormatterResult.Failure());
118+
}
119+
120+
return Task.FromResult(InputFormatterResult.Success(modelReader.ReadModel(context.HttpContext.Request.Body, SerializerOptions)));
121+
}
122+
}
123+
}

src/Ndjson.AsyncStreams.AspNetCore.Mvc/Internals/NdjsonWriter.cs renamed to src/Ndjson.AsyncStreams.AspNetCore.Mvc/Internals/SystemTextNdjsonWriter.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99

1010
namespace Ndjson.AsyncStreams.AspNetCore.Mvc.Internals
1111
{
12-
internal class NdjsonWriter<T> : INdjsonWriter<T>
12+
internal class SystemTextNdjsonWriter<T> : INdjsonWriter<T>
1313
{
1414
private static readonly byte[] _newlineDelimiter = Encoding.UTF8.GetBytes("\n");
1515

1616
private readonly Stream _writeStream;
1717
private readonly JsonSerializerOptions _jsonSerializerOptions;
1818

19-
public NdjsonWriter(Stream writeStream, JsonOptions jsonOptions)
19+
public SystemTextNdjsonWriter(Stream writeStream, JsonOptions jsonOptions)
2020
{
2121
_writeStream = writeStream ?? throw new ArgumentNullException(nameof(Stream));
2222

0 commit comments

Comments
 (0)