简体   繁体   中英

Unable to get ASP.NET web api to consume an unspecified content type

I am trying to have a web api (controller) consume an HTTP post request where the client does not specify the accept/contenttype header.

I have 2 methods in the controller - one consumes text/plain (legacy) and another which consumes application/json (new). The problem is we have legacy environments that expect a text-based request/response but they are not specifying the content type with the request. Since these environments will not be upgraded for some time i'm not sure how to force it to process the text/plain as the default media type.

When i do a request without specifying the content type i get a 'Unsupported media type' error.

The Consumes attribute does not support a null media type.

I was able to resolve this situation by creating a custom "Consume" attribute using the MVC code as a template and creating my own InputFormatter.

The attribute:

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) && !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 &&
                                                            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);
            }
        }
    }
}

and the formatter:

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();
        }
    }
}

in your configure services method you need to add the following:

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

and finally on your controller method you decorate it with the new attribute and a [FromBody] parameter:

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

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM