#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)
{
...
}