File: NewtonsoftJsonInputFormatter.cs
Web Access
Project: src\src\Mvc\Mvc.NewtonsoftJson\src\Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj (Microsoft.AspNetCore.Mvc.NewtonsoftJson)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Buffers;
using System.Globalization;
using System.Runtime.ExceptionServices;
using System.Text;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.NewtonsoftJson;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.ObjectPool;
using Newtonsoft.Json;
 
namespace Microsoft.AspNetCore.Mvc.Formatters;
 
/// <summary>
/// A <see cref="TextInputFormatter"/> for JSON content.
/// </summary>
public partial class NewtonsoftJsonInputFormatter : TextInputFormatter, IInputFormatterExceptionPolicy
{
    private readonly IArrayPool<char> _charPool;
    private readonly ILogger _logger;
    private readonly ObjectPoolProvider _objectPoolProvider;
    private readonly MvcOptions _options;
    private readonly MvcNewtonsoftJsonOptions _jsonOptions;
 
    private ObjectPool<JsonSerializer>? _jsonSerializerPool;
 
    /// <summary>
    /// Initializes a new instance of <see cref="NewtonsoftJsonInputFormatter"/>.
    /// </summary>
    /// <param name="logger">The <see cref="ILogger"/>.</param>
    /// <param name="serializerSettings">
    /// The <see cref="JsonSerializerSettings"/>. Should be either the application-wide settings
    /// (<see cref="MvcNewtonsoftJsonOptions.SerializerSettings"/>) or an instance
    /// <see cref="JsonSerializerSettingsProvider.CreateSerializerSettings"/> initially returned.
    /// </param>
    /// <param name="charPool">The <see cref="ArrayPool{Char}"/>.</param>
    /// <param name="objectPoolProvider">The <see cref="ObjectPoolProvider"/>.</param>
    /// <param name="options">The <see cref="MvcOptions"/>.</param>
    /// <param name="jsonOptions">The <see cref="MvcNewtonsoftJsonOptions"/>.</param>
    public NewtonsoftJsonInputFormatter(
        ILogger logger,
        JsonSerializerSettings serializerSettings,
        ArrayPool<char> charPool,
        ObjectPoolProvider objectPoolProvider,
        MvcOptions options,
        MvcNewtonsoftJsonOptions jsonOptions)
    {
        ArgumentNullException.ThrowIfNull(logger);
        ArgumentNullException.ThrowIfNull(serializerSettings);
        ArgumentNullException.ThrowIfNull(charPool);
        ArgumentNullException.ThrowIfNull(objectPoolProvider);
 
        _logger = logger;
        SerializerSettings = serializerSettings;
        _charPool = new JsonArrayPool<char>(charPool);
        _objectPoolProvider = objectPoolProvider;
        _options = options;
        _jsonOptions = jsonOptions;
 
        SupportedEncodings.Add(UTF8EncodingWithoutBOM);
        SupportedEncodings.Add(UTF16EncodingLittleEndian);
 
        SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationJson);
        SupportedMediaTypes.Add(MediaTypeHeaderValues.TextJson);
        SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationAnyJsonSyntax);
    }
 
    /// <inheritdoc />
    public virtual InputFormatterExceptionPolicy ExceptionPolicy
    {
        get
        {
            if (GetType() == typeof(NewtonsoftJsonInputFormatter))
            {
                return InputFormatterExceptionPolicy.MalformedInputExceptions;
            }
            return InputFormatterExceptionPolicy.AllExceptions;
        }
    }
 
    /// <summary>
    /// Gets the <see cref="JsonSerializerSettings"/> used to configure the <see cref="JsonSerializer"/>.
    /// </summary>
    /// <remarks>
    /// Any modifications to the <see cref="JsonSerializerSettings"/> object after this
    /// <see cref="NewtonsoftJsonInputFormatter"/> has been used will have no effect.
    /// </remarks>
    protected JsonSerializerSettings SerializerSettings { get; }
 
    /// <inheritdoc />
    public override async Task<InputFormatterResult> ReadRequestBodyAsync(
        InputFormatterContext context,
        Encoding encoding)
    {
        ArgumentNullException.ThrowIfNull(context);
        ArgumentNullException.ThrowIfNull(encoding);
 
        var httpContext = context.HttpContext;
        var request = httpContext.Request;
 
        var suppressInputFormatterBuffering = _options.SuppressInputFormatterBuffering;
 
        var readStream = request.Body;
        var disposeReadStream = false;
        if (readStream.CanSeek)
        {
            // The most common way of getting here is the user has request buffering on.
            // However, request buffering isn't eager, and consequently it will peform pass-thru synchronous
            // reads as part of the deserialization.
            // To avoid this, drain and reset the stream.
            var position = request.Body.Position;
            await readStream.DrainAsync(CancellationToken.None);
            readStream.Position = position;
        }
        else if (!suppressInputFormatterBuffering)
        {
            // JSON.Net does synchronous reads. In order to avoid blocking on the stream, we asynchronously
            // read everything into a buffer, and then seek back to the beginning.
            var memoryThreshold = _jsonOptions.InputFormatterMemoryBufferThreshold;
            var contentLength = request.ContentLength.GetValueOrDefault();
            if (contentLength > 0 && contentLength < memoryThreshold)
            {
                // If the Content-Length is known and is smaller than the default buffer size, use it.
                memoryThreshold = (int)contentLength;
            }
 
            readStream = new FileBufferingReadStream(request.Body, memoryThreshold);
            // Ensure the file buffer stream is always disposed at the end of a request.
            httpContext.Response.RegisterForDispose(readStream);
 
            await readStream.DrainAsync(CancellationToken.None);
            readStream.Seek(0L, SeekOrigin.Begin);
 
            disposeReadStream = true;
        }
 
        var successful = true;
        Exception? exception = null;
        object? model;
 
        using (var streamReader = context.ReaderFactory(readStream, encoding))
        {
            using var jsonReader = new JsonTextReader(streamReader);
            jsonReader.ArrayPool = _charPool;
            jsonReader.CloseInput = false;
 
            var type = context.ModelType;
            var jsonSerializer = CreateJsonSerializer(context);
            jsonSerializer.Error += ErrorHandler;
 
            if (_jsonOptions.ReadJsonWithRequestCulture)
            {
                jsonSerializer.Culture = CultureInfo.CurrentCulture;
            }
 
            try
            {
                model = jsonSerializer.Deserialize(jsonReader, type);
            }
            finally
            {
                // Clean up the error handler since CreateJsonSerializer() pools instances.
                jsonSerializer.Error -= ErrorHandler;
                ReleaseJsonSerializer(jsonSerializer);
 
                if (disposeReadStream)
                {
                    await readStream.DisposeAsync();
                }
            }
        }
 
        if (successful)
        {
            if (model == null && !context.TreatEmptyInputAsDefaultValue)
            {
                // Some nonempty inputs might deserialize as null, for example whitespace,
                // or the JSON-encoded value "null". The upstream BodyModelBinder needs to
                // be notified that we don't regard this as a real input so it can register
                // a model binding error.
                return InputFormatterResult.NoValue();
            }
            else
            {
                return InputFormatterResult.Success(model);
            }
        }
 
        if (exception is not null && exception is not (JsonException or OverflowException or FormatException))
        {
            // At this point we've already recorded all exceptions as an entry in the ModelStateDictionary.
            // We only need to rethrow an exception if we believe it needs to be handled by something further up
            // the stack.
            // JsonException, OverflowException, and FormatException are assumed to be only encountered when
            // parsing the JSON and are consequently "safe" to be exposed as part of ModelState. Everything else
            // needs to be rethrown.
 
            var exceptionDispatchInfo = ExceptionDispatchInfo.Capture(exception);
            exceptionDispatchInfo.Throw();
        }
 
        return InputFormatterResult.Failure();
 
        void ErrorHandler(object? sender, Newtonsoft.Json.Serialization.ErrorEventArgs eventArgs)
        {
            // Skipping error, if it's already marked as handled
            // This allows user code to implement its own error handling
            if (eventArgs.ErrorContext.Handled)
            {
                return;
            }
 
            successful = false;
 
            // The following addMember logic is intended to append the names of missing required properties to the
            // ModelStateDictionary key. Normally, just the ModelName and ErrorContext.Path is used for this key,
            // but ErrorContext.Path does not include the missing required property name like we want it to.
            // For example, given the following class and input missing the required "Name" property:
            //
            // class Person
            // {
            //     [JsonProperty(Required = Required.Always)]
            //     public string Name { get; set; }
            // }
            //
            // We will see the following ErrorContext:
            //
            // Error   {"Required property 'Name' not found in JSON. Path 'Person'..."} System.Exception {Newtonsoft.Json.JsonSerializationException}
            // Member  "Name"   object {string}
            // Path    "Person" string
            //
            // So we update the path used for the ModelStateDictionary key to be "Person.Name" instead of just "Person".
            // See https://github.com/aspnet/Mvc/issues/8509
            var path = eventArgs.ErrorContext.Path;
            var member = eventArgs.ErrorContext.Member as string;
 
            // There are some deserialization exceptions that include the member in the path but not at the end.
            // For example, given the following classes and invalid input like { "b": { "c": { "d": abc } } }:
            //
            // class A
            // {
            //     public B B { get; set; }
            // }
            // class B
            // {
            //     public C C { get; set; }
            // }
            // class C
            // {
            //     public string D { get; set; }
            // }
            //
            // We will see the following ErrorContext:
            //
            // Error   {"Unexpected character encountered while parsing value: b. Path 'b.c.d'..."} System.Exception {Newtonsoft.Json.JsonReaderException}
            // Member  "c"     object {string}
            // Path    "b.c.d" string
            //
            // Notice that Member "c" is in the middle of the Path "b.c.d". The error handler gets invoked for each level of nesting.
            // null, "b", "c" and "d" are each a Member in different ErrorContexts all reporting the same parsing error.
            //
            // The parsing error is reported as a JsonReaderException instead of as a JsonSerializationException like
            // for missing required properties. We use the exception type to filter out these errors and keep the path used
            // for the ModelStateDictionary key as "b.c.d" instead of "b.c.d.c"
            // See https://github.com/dotnet/aspnetcore/issues/33451
            var addMember = !string.IsNullOrEmpty(member) && eventArgs.ErrorContext.Error is JsonSerializationException;
 
            // There are still JsonSerilizationExceptions that set ErrorContext.Member but include it at the
            // end of ErrorContext.Path already. The following logic attempts to filter these out.
            if (addMember)
            {
                // Path.Member case (path.Length < member.Length) needs no further checks.
                if (path.Length == member!.Length)
                {
                    // Add Member in Path.Member case but not for Path.Path.
                    addMember = !string.Equals(path, member, StringComparison.Ordinal);
                }
                else if (path.Length > member.Length)
                {
                    // Finally, check whether Path already ends or starts with Member.
                    if (member[0] == '[')
                    {
                        addMember = !path.EndsWith(member, StringComparison.Ordinal);
                    }
                    else
                    {
                        addMember = !path.EndsWith($".{member}", StringComparison.Ordinal)
                            && !path.EndsWith($"['{member}']", StringComparison.Ordinal)
                            && !path.EndsWith($"[{member}]", StringComparison.Ordinal);
                    }
                }
            }
 
            if (addMember)
            {
                path = ModelNames.CreatePropertyModelName(path, member);
            }
 
            // Handle path combinations such as ""+"Property", "Parent"+"Property", or "Parent"+"[12]".
            var key = ModelNames.CreatePropertyModelName(context.ModelName, path);
 
            exception = eventArgs.ErrorContext.Error;
 
            var metadata = GetPathMetadata(context.Metadata, path);
            var modelStateException = WrapExceptionForModelState(exception);
            context.ModelState.TryAddModelError(key, modelStateException, metadata);
            Log.JsonInputException(_logger, exception);
 
            // Error must always be marked as handled
            // Failure to do so can cause the exception to be rethrown at every recursive level and
            // overflow the stack for x64 CLR processes
            eventArgs.ErrorContext.Handled = true;
        }
    }
 
    /// <summary>
    /// Called during deserialization to get the <see cref="JsonSerializer"/>. The formatter context
    /// that is passed gives an ability to create serializer specific to the context.
    /// </summary>
    /// <returns>The <see cref="JsonSerializer"/> used during deserialization.</returns>
    /// <remarks>
    /// This method works in tandem with <see cref="ReleaseJsonSerializer(JsonSerializer)"/> to
    /// manage the lifetimes of <see cref="JsonSerializer"/> instances.
    /// </remarks>
    protected virtual JsonSerializer CreateJsonSerializer()
    {
        if (_jsonSerializerPool == null)
        {
            _jsonSerializerPool = _objectPoolProvider.Create(new JsonSerializerObjectPolicy(SerializerSettings));
        }
 
        return _jsonSerializerPool.Get();
    }
 
    /// <summary>
    /// Called during deserialization to get the <see cref="JsonSerializer"/>. The formatter context
    /// that is passed gives an ability to create serializer specific to the context.
    /// </summary>
    /// <param name="context">A context object used by an input formatter for deserializing the request body into an object.</param>
    /// <returns>The <see cref="JsonSerializer"/> used during deserialization.</returns>
    /// <remarks>
    /// This method works in tandem with <see cref="ReleaseJsonSerializer(JsonSerializer)"/> to
    /// manage the lifetimes of <see cref="JsonSerializer"/> instances.
    /// </remarks>
    protected virtual JsonSerializer CreateJsonSerializer(InputFormatterContext context)
    {
        return CreateJsonSerializer();
    }
 
    /// <summary>
    /// Releases the <paramref name="serializer"/> instance.
    /// </summary>
    /// <param name="serializer">The <see cref="JsonSerializer"/> to release.</param>
    /// <remarks>
    /// This method works in tandem with <see cref="ReleaseJsonSerializer(JsonSerializer)"/> to
    /// manage the lifetimes of <see cref="JsonSerializer"/> instances.
    /// </remarks>
    protected virtual void ReleaseJsonSerializer(JsonSerializer serializer)
        => _jsonSerializerPool!.Return(serializer);
 
    private static ModelMetadata GetPathMetadata(ModelMetadata metadata, string path)
    {
        var index = 0;
        while (index >= 0 && index < path.Length)
        {
            if (path[index] == '[')
            {
                // At start of "[0]".
                if (metadata.ElementMetadata == null)
                {
                    // Odd case but don't throw just because ErrorContext had an odd-looking path.
                    break;
                }
 
                metadata = metadata.ElementMetadata;
                index = path.IndexOf(']', index);
            }
            else if (path[index] == '.' || path[index] == ']')
            {
                // Skip '.' in "prefix.property" or "[0].property" or ']' in "[0]".
                index++;
            }
            else
            {
                // At start of "property", "property." or "property[0]".
                var endIndex = path.AsSpan(index).IndexOfAny('.', '[');
                if (endIndex < 0)
                {
                    endIndex = path.Length;
                }
                else
                {
                    endIndex += index;
                }
 
                var propertyName = path.Substring(index, endIndex - index);
                var propertyMetadata = metadata.Properties[propertyName];
                if (propertyMetadata is null)
                {
                    // Odd case but don't throw just because ErrorContext had an odd-looking path.
                    break;
                }
                metadata = propertyMetadata;
                index = endIndex;
            }
        }
 
        return metadata;
    }
 
    private Exception WrapExceptionForModelState(Exception exception)
    {
        // In 2.0 and earlier we always gave a generic error message for errors that come from JSON.NET
        // We only allow it in 2.1 and newer if the app opts-in.
        if (!_jsonOptions.AllowInputFormatterExceptionMessages)
        {
            // This app is not opted-in to JSON.NET messages, return the original exception.
            return exception;
        }
 
        // It's not known that Json.NET currently ever raises error events with exceptions
        // other than these two types, but we're being conservative and limiting which ones
        // we regard as having safe messages to expose to clients
        if (exception is JsonReaderException || exception is JsonSerializationException)
        {
            // InputFormatterException specifies that the message is safe to return to a client, it will
            // be added to model state.
            return new InputFormatterException(exception.Message, exception);
        }
 
        // Not a known exception type, so we're not going to assume that it's safe.
        return exception;
    }
 
    private static partial class Log
    {
        [LoggerMessage(1, LogLevel.Debug, "JSON input formatter threw an exception.", EventName = "JsonInputException")]
        public static partial void JsonInputException(ILogger logger, Exception exception);
    }
}