Невозможно получить ASP.NET веб-api для использования неопределенного типа контента

#c# #asp.net-core #model-view-controller #mime-types #content-type

#c# #asp.net-core #модель-представление-контроллер #mime-типы #тип содержимого

Вопрос:

Я пытаюсь заставить веб-api (контроллер) использовать HTTP post-запрос, в котором клиент не указывает заголовок accept / contenttype.

У меня есть 2 метода в контроллере — один использует текст / обычный (устаревший), а другой использует application / json (новый). Проблема в том, что у нас есть устаревшие среды, которые ожидают текстовый запрос / ответ, но они не указывают тип контента с запросом. Поскольку эти среды не будут обновляться в течение некоторого времени, я не уверен, как заставить его обрабатывать текст / обычный в качестве типа носителя по умолчанию.

Когда я выполняю запрос без указания типа контента, я получаю сообщение об ошибке «Неподдерживаемый тип носителя».

Атрибут Consumes не поддерживает тип носителя null.

Ответ №1:

Я смог разрешить эту ситуацию, создав пользовательский атрибут «Consume», используя код MVC в качестве шаблона и создав свой собственный InputFormatter.

Атрибут:

 using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Formatters;




namespace MyNamespace
{


    internal interface IConsumesNullConstraint : IActionConstraint
    { }
    /// <summary>
    /// A filter that specifies the supported request content types. <see cref="ContentTypes"/>
    /// </summary>
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
    public class ConsumesNullOrTextAttribute :
            Attribute,
            IResourceFilter,
            IActionConstraint,
            IConsumesNullConstraint,
            IApiRequestMetadataProvider
    {
        /// <summary>
        /// The order for consumes attribute.
        /// </summary>
        /// <value>Defaults to 200</value>
        public static readonly int ConsumesActionConstraintOrder = 200;

        /// <summary>
        /// Creates a new instance of <see cref="ConsumesNullOrTextAttribute"/>.
        /// </summary>
        public ConsumesNullOrTextAttribute()
        {


            ContentTypes = GetContentTypes("text/plain");
        }

        // The value used is a non default value so that it avoids getting mixed with other action constraints
        // with default order.
        /// <inheritdoc />
        int IActionConstraint.Order => ConsumesActionConstraintOrder;

        /// <summary>
        /// Media types to support in addition to null
        /// </summary>
        public MediaTypeCollection ContentTypes { get; set; }

        /// <summary>
        /// Validates it is an expected media type we want to support
        /// </summary>
        /// <param name="context"></param>
        public void OnResourceExecuting(ResourceExecutingContext context)
        {
            if (context == null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            // Only execute if the current filter is the one which is closest to the action.
            // Ignore all other filters. This is to ensure we have a overriding behavior.
            if (IsApplicable(context.ActionDescriptor))
            {
                var requestContentType = context.HttpContext.Request.ContentType;


                // If we got a content type - make sure it is a supported type
                if (!string.IsNullOrEmpty(requestContentType) amp;amp; !IsSubsetOfAnyContentType(requestContentType))
                {
                    context.Result = new UnsupportedMediaTypeResult();
                }
            }
        }

        private bool IsSubsetOfAnyContentType(string requestMediaType)
        {
            var parsedRequestMediaType = new MediaType(requestMediaType);
            for (var i = 0; i < ContentTypes.Count; i  )
            {
                var contentTypeMediaType = new MediaType(ContentTypes[i]);
                if (parsedRequestMediaType.IsSubsetOf(contentTypeMediaType))
                {
                    return true;
                }
            }
            return false;
        }

        /// <inheritdoc />
        public void OnResourceExecuted(ResourceExecutedContext context)
        {
            if (context == null)
            {
                throw new ArgumentNullException(nameof(context));
            }
        }

        /// <inheritdoc />
        public bool Accept(ActionConstraintContext context)
        {
            // If this constraint is not closest to the action, it will be skipped.
            if (!IsApplicable(context.CurrentCandidate.Action))
            {
                // Since the constraint is to be skipped, returning true here
                // will let the current candidate ignore this constraint and will
                // be selected based on other constraints for this action.
                return true;
            }

            var requestContentType = context.RouteContext.HttpContext.Request.ContentType;

            // If null, we are okay
            if (string.IsNullOrEmpty(requestContentType))
            {
                return true;
            }

            // Confirm the request's content type is more specific than (a media type this action supports e.g. OK
            // if client sent "text/plain" data and this action supports "text/*".
            if (IsSubsetOfAnyContentType(requestContentType))
            {
                return true;
            }

            var firstCandidate = context.Candidates[0];
            if (firstCandidate.Action != context.CurrentCandidate.Action)
            {
                // If the current candidate is not same as the first candidate,
                // we need not probe other candidates to see if they apply.
                // Only the first candidate is allowed to probe other candidates and based on the result select itself.
                return false;
            }

            // Run the matching logic for all IConsumesActionConstraints we can find, and see what matches.
            // 1). If we have a unique best match, then only that constraint should return true.
            // 2). If we have multiple matches, then all constraints that match will return true
            // , resulting in ambiguity(maybe).
            // 3). If we have no matches, then we choose the first constraint to return true.It will later return a 415
            foreach (var candidate in context.Candidates)
            {
                if (candidate.Action == firstCandidate.Action)
                {
                    continue;
                }

                var tempContext = new ActionConstraintContext()
                {
                    Candidates = context.Candidates,
                    RouteContext = context.RouteContext,
                    CurrentCandidate = candidate
                };

                if (candidate.Constraints == null || candidate.Constraints.Count == 0 ||
                    candidate.Constraints.Any(constraint => constraint is IConsumesNullConstraint amp;amp;
                                                            constraint.Accept(tempContext)))
                {
                    // There is someone later in the chain which can handle the request.
                    // end the process here.
                    return false;
                }
            }

            // There is no one later in the chain that can handle this content type return a false positive so that
            // later we can detect and return a 415.
            return true;
        }

        private bool IsApplicable(ActionDescriptor actionDescriptor)
        {
            // If there are multiple IConsumeActionConstraints which are defined at the class and
            // at the action level, the one closest to the action overrides the others. To ensure this
            // we take advantage of the fact that ConsumesAttribute is both an IActionFilter and an
            // IConsumeActionConstraint. Since FilterDescriptor collection is ordered (the last filter is the one
            // closest to the action), we apply this constraint only if there is no IConsumeActionConstraint after this.
            return actionDescriptor.FilterDescriptors.Last(
                filter => filter.Filter is IConsumesNullConstraint).Filter == this;
        }

        private MediaTypeCollection GetContentTypes(string firstArg, string[] args = null)
        {
            var completeArgs = new List<string>((args?.Length ?? 0)   1);
            completeArgs.Add(firstArg);
            if (args != null)
                completeArgs.AddRange(args);
            var contentTypes = new MediaTypeCollection();
            foreach (var arg in completeArgs)
            {
                var mediaType = new MediaType(arg);
                if (mediaType.MatchesAllSubTypes ||
                    mediaType.MatchesAllTypes)
                {
                    throw new InvalidOperationException(
                      $"Unsupported media type {arg}");
                }

                contentTypes.Add(arg);
            }

            return contentTypes;
        }

        /// <inheritdoc />
        public void SetContentTypes(MediaTypeCollection contentTypes)
        {
            contentTypes.Clear();
            foreach (var contentType in ContentTypes)
            {
                contentTypes.Add(contentType);
            }
        }
    }
}
 

и форматировщик:

 using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Net.Http.Headers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;

namespace MyNameSpace
{
    /// <summary>
    /// Formatter that allows content of type text/plain 
    /// or no content type to be parsed to raw data. Allows for a single input parameter
    /// in the form of:
    /// 
    /// public string RawString([FromBody] string data)
    /// public byte[] RawData([FromBody] byte[] data)
    /// </summary>
    public class RawRequestBodyFormatter : InputFormatter
    {
        public RawRequestBodyFormatter()
        {
            SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/plain"));
            SupportedMediaTypes.Add((string)null);
        }


        /// <summary>
        /// Allow text/plain, application/octet-stream and no content type to
        /// be processed
        /// </summary>
        /// <param name="context"></param>
        /// <returns></returns>
        public override Boolean CanRead(InputFormatterContext context)
        {
            if (context == null) throw new ArgumentNullException(nameof(context));

            var contentType = context.HttpContext.Request.ContentType;
            if (string.IsNullOrEmpty(contentType) || contentType == "text/plain")
                return true;

            return false;
        }

        /// <summary>
        /// Handle text/plain or no content type for string results
        /// Handle application/octet-stream for byte[] results
        /// </summary>
        /// <param name="context"></param>
        /// <returns></returns>
        public override async Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context)
        {
            var request = context.HttpContext.Request;
            var contentType = context.HttpContext.Request.ContentType;

            // process stream as text
            if (string.IsNullOrEmpty(contentType) || contentType == "text/plain")
            {
                using (var reader = new StreamReader(request.Body))
                {
                    var content = await reader.ReadToEndAsync();
                    return await InputFormatterResult.SuccessAsync(content);
                }
            }

            return await InputFormatterResult.FailureAsync();
        }
    }
}
 

в вашем методе configure services вам необходимо добавить следующее:

 public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddControllers(o => o.InputFormatters.Insert(o.InputFormatters.Count, new RawRequestBodyFormatter()));
    ...
}
 

и, наконец, в вашем методе контроллера вы украшаете его новым атрибутом и параметром [FromBody]:

 [HttpPost]
[Route("api/MyAPI")]
[ConsumesNullOrText()]
public async Task<string> MyApiHandler([FromBody] string content)
{
    ...
}