From 4f351bd37c0f77b891a26763d535c04f831554d0 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 31 Mar 2017 10:20:43 +0100 Subject: [PATCH] Add support for media type suffixes (#5273, #6032) --- .../OutputFormatterCanWriteContext.cs | 8 + .../Formatters/MediaType.cs | 163 +++++++++++++++++- .../Formatters/OutputFormatter.cs | 76 +++++--- .../Internal/ObjectResultExecutor.cs | 7 +- .../ProducesAttribute.cs | 3 +- .../Internal/MediaTypeHeaderValues.cs | 3 + .../Internal/MvcJsonMvcOptionsSetup.cs | 15 +- .../JsonInputFormatter.cs | 1 + .../JsonOutputFormatter.cs | 1 + .../Internal/MediaTypeHeaderValues.cs | 3 + ...XmlDataContractSerializerInputFormatter.cs | 1 + ...mlDataContractSerializerOutputFormatter.cs | 1 + .../XmlSerializerInputFormatter.cs | 1 + .../XmlSerializerOutputFormatter.cs | 1 + .../Formatters/MediaTypeTest.cs | 122 +++++++++++-- .../Formatters/OutputFormatterTests.cs | 63 ++++++- .../ObjectResultExecutorTest.cs | 112 ++++++++++++ .../ProducesAttributeTests.cs | 2 + .../JsonInputFormatterTest.cs | 5 + .../JsonOutputFormatterTests.cs | 43 +++++ ...ataContractSerializerInputFormatterTest.cs | 5 + ...taContractSerializerOutputFormatterTest.cs | 38 +++- .../XmlSerializerInputFormatterTest.cs | 5 + .../XmlSerializerOutputFormatterTest.cs | 36 +++- .../ApiExplorerTest.cs | 19 ++ .../ConsumesAttributeTests.cs | 42 +++++ .../ContentNegotiationTest.cs | 40 +++++ .../MvcOptionsSetupTest.cs | 4 +- ...piExplorerResponseContentTypeController.cs | 9 +- .../ConsumesAttribute_MediaTypeSuffix.cs | 30 ++++ ...ProducesWithMediaTypeSuffixesController.cs | 21 +++ 31 files changed, 821 insertions(+), 59 deletions(-) create mode 100644 test/WebSites/BasicWebSite/Controllers/ActionConstraints/ConsumesAttribute_MediaTypeSuffix.cs create mode 100644 test/WebSites/BasicWebSite/Controllers/ContentNegotiation/ProducesWithMediaTypeSuffixesController.cs diff --git a/src/Microsoft.AspNetCore.Mvc.Abstractions/Formatters/OutputFormatterCanWriteContext.cs b/src/Microsoft.AspNetCore.Mvc.Abstractions/Formatters/OutputFormatterCanWriteContext.cs index 979dd749aa..84e34fdf46 100644 --- a/src/Microsoft.AspNetCore.Mvc.Abstractions/Formatters/OutputFormatterCanWriteContext.cs +++ b/src/Microsoft.AspNetCore.Mvc.Abstractions/Formatters/OutputFormatterCanWriteContext.cs @@ -56,6 +56,14 @@ namespace Microsoft.AspNetCore.Mvc.Formatters /// public virtual StringSegment ContentType { get; set; } + /// + /// Gets or sets a value to indicate whether the content type was specified by server-side code. + /// This allows to + /// implement stricter filtering on content types that, for example, are being considered purely + /// of an incoming Accept header. + /// + public virtual bool ContentTypeIsServerDefined { get; set; } + /// /// Gets or sets the object to write to the response. /// diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Formatters/MediaType.cs b/src/Microsoft.AspNetCore.Mvc.Core/Formatters/MediaType.cs index 1c7e008657..3eda392005 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Formatters/MediaType.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Formatters/MediaType.cs @@ -76,6 +76,8 @@ namespace Microsoft.AspNetCore.Mvc.Formatters { Type = new StringSegment(); SubType = new StringSegment(); + SubTypeWithoutSuffix = new StringSegment(); + SubTypeSuffix = new StringSegment(); return; } else @@ -88,11 +90,24 @@ namespace Microsoft.AspNetCore.Mvc.Formatters if (subTypeLength == 0) { SubType = new StringSegment(); + SubTypeWithoutSuffix = new StringSegment(); + SubTypeSuffix = new StringSegment(); return; } else { SubType = subType; + + if (TryGetSuffixLength(subType, out var subtypeSuffixLength)) + { + SubTypeWithoutSuffix = subType.Subsegment(0, subType.Length - subtypeSuffixLength - 1); + SubTypeSuffix = subType.Subsegment(subType.Length - subtypeSuffixLength, subtypeSuffixLength); + } + else + { + SubTypeWithoutSuffix = SubType; + SubTypeSuffix = new StringSegment(); + } } _parameterParser = new MediaTypeParameterParser(mediaType, offset + typeLength + subTypeLength, length); @@ -158,9 +173,29 @@ namespace Microsoft.AspNetCore.Mvc.Formatters return current - offset; } + private static bool TryGetSuffixLength(StringSegment subType, out int suffixLength) + { + // Find the last instance of '+', if there is one + var startPos = subType.Offset + subType.Length - 1; + for (var currentPos = startPos; currentPos >= subType.Offset; currentPos--) + { + if (subType.Buffer[currentPos] == '+') + { + suffixLength = startPos - currentPos; + return true; + } + } + + suffixLength = 0; + return false; + } + /// /// Gets the type of the . /// + /// + /// For the media type "application/json", the property gives the value "application". + /// public StringSegment Type { get; } /// @@ -171,13 +206,52 @@ namespace Microsoft.AspNetCore.Mvc.Formatters /// /// Gets the subtype of the . /// + /// + /// For the media type "application/vnd.example+json", the property gives the value + /// "vnd.example+json". + /// public StringSegment SubType { get; private set; } + /// + /// Gets the subtype of the , excluding any structured syntax suffix. + /// + /// + /// For the media type "application/vnd.example+json", the property gives the value + /// "vnd.example". + /// + public StringSegment SubTypeWithoutSuffix { get; private set; } + + /// + /// Gets the structured syntax suffix of the if it has one. + /// + /// + /// For the media type "application/vnd.example+json", the property gives the value + /// "json". + /// + public StringSegment SubTypeSuffix { get; private set; } + /// /// Gets whether this matches all subtypes. /// + /// + /// For the media type "application/*", this property is true. + /// + /// + /// For the media type "application/json", this property is false. + /// public bool MatchesAllSubTypes => SubType.Equals("*", StringComparison.OrdinalIgnoreCase); + /// + /// Gets whether this matches all subtypes, ignoring any structured syntax suffix. + /// + /// + /// For the media type "application/*+json", this property is true. + /// + /// + /// For the media type "application/vnd.example+json", this property is false. + /// + public bool MatchesAllSubTypesWithoutSuffix => SubTypeWithoutSuffix.Equals("*", StringComparison.OrdinalIgnoreCase); + /// /// Gets the of the if it has one. /// @@ -188,6 +262,22 @@ namespace Microsoft.AspNetCore.Mvc.Formatters /// public StringSegment Charset => GetParameter("charset"); + /// + /// Determines whether the current contains a wildcard. + /// + /// + /// true if this contains a wildcard; otherwise false. + /// + public bool HasWildcard + { + get + { + return MatchesAllTypes || + MatchesAllSubTypesWithoutSuffix || + GetParameter("*").Equals("*", StringComparison.OrdinalIgnoreCase); + } + } + /// /// Determines whether the current is a subset of the /// . @@ -198,8 +288,8 @@ namespace Microsoft.AspNetCore.Mvc.Formatters /// public bool IsSubsetOf(MediaType set) { - return (set.MatchesAllTypes || set.Type.Equals(Type, StringComparison.OrdinalIgnoreCase)) && - (set.MatchesAllSubTypes || set.SubType.Equals(SubType, StringComparison.OrdinalIgnoreCase)) && + return MatchesType(set) && + MatchesSubtype(set) && ContainsAllParameters(set._parameterParser); } @@ -372,6 +462,53 @@ namespace Microsoft.AspNetCore.Mvc.Formatters return $"{mediaType.Value}; charset={encoding.WebName}"; } + private bool MatchesType(MediaType set) + { + return set.MatchesAllTypes || + set.Type.Equals(Type, StringComparison.OrdinalIgnoreCase); + } + + private bool MatchesSubtype(MediaType set) + { + if (set.MatchesAllSubTypes) + { + return true; + } + + if (set.SubTypeSuffix.HasValue) + { + if (SubTypeSuffix.HasValue) + { + // Both the set and the media type being checked have suffixes, so both parts must match. + return MatchesSubtypeWithoutSuffix(set) && MatchesSubtypeSuffix(set); + } + else + { + // The set has a suffix, but the media type being checked doesn't. We never consider this to match. + return false; + } + } + else + { + // The set has no suffix, so we're just looking for an exact match (which means that if 'this' + // has a suffix, it won't match). + return set.SubType.Equals(SubType, StringComparison.OrdinalIgnoreCase); + } + } + + private bool MatchesSubtypeWithoutSuffix(MediaType set) + { + return set.MatchesAllSubTypesWithoutSuffix || + set.SubTypeWithoutSuffix.Equals(SubTypeWithoutSuffix, StringComparison.OrdinalIgnoreCase); + } + + private bool MatchesSubtypeSuffix(MediaType set) + { + // We don't have support for wildcards on suffixes alone (e.g., "application/entity+*") + // because there's no clear use case for it. + return set.SubTypeSuffix.Equals(SubTypeSuffix, StringComparison.OrdinalIgnoreCase); + } + private bool ContainsAllParameters(MediaTypeParameterParser setParameters) { var parameterFound = true; @@ -385,6 +522,13 @@ namespace Microsoft.AspNetCore.Mvc.Formatters break; } + if (setParameter.HasName("*")) + { + // A parameter named "*" has no effect on media type matching, as it is only used as an indication + // that the entire media type string should be treated as a wildcard. + continue; + } + // Copy the parser as we need to iterate multiple times over it. // We can do this because it's a struct var subSetParameters = _parameterParser; @@ -452,8 +596,19 @@ namespace Microsoft.AspNetCore.Mvc.Formatters if (nameLength == 0 || OffsetIsOutOfRange(current, input.Length) || input[current] != '=') { - parsedValue = default(MediaTypeParameter); - return 0; + if (current == input.Length && name.Equals("*", StringComparison.OrdinalIgnoreCase)) + { + // As a special case, we allow a trailing ";*" to indicate a wildcard + // string allowing any other parameters. It's the same as ";*=*". + var asterisk = new StringSegment("*"); + parsedValue = new MediaTypeParameter(asterisk, asterisk); + return current - startIndex; + } + else + { + parsedValue = default(MediaTypeParameter); + return 0; + } } StringSegment value; diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Formatters/OutputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Core/Formatters/OutputFormatter.cs index 28b0007641..7eb026e584 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Formatters/OutputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Formatters/OutputFormatter.cs @@ -49,24 +49,35 @@ namespace Microsoft.AspNetCore.Mvc.Formatters { return null; } + + List mediaTypes = null; - if (contentType == null) + var parsedContentType = contentType != null ? new MediaType(contentType) : default(MediaType); + + foreach (var mediaType in SupportedMediaTypes) { - // If contentType is null, then any type we support is valid. - return SupportedMediaTypes; - } - else - { - List mediaTypes = null; - - var parsedContentType = new MediaType(contentType); - - // Confirm this formatter supports a more specific media type than requested e.g. OK if "text/*" - // requested and formatter supports "text/plain". Treat contentType like it came from an Accept header. - foreach (var mediaType in SupportedMediaTypes) + var parsedMediaType = new MediaType(mediaType); + if (parsedMediaType.HasWildcard) { - var parsedMediaType = new MediaType(mediaType); - if (parsedMediaType.IsSubsetOf(parsedContentType)) + // For supported media types that are wildcard patterns, confirm that the requested + // media type satisfies the wildcard pattern (e.g., if "text/entity+json;v=2" requested + // and formatter supports "text/*+json"). + // Treat contentType like it came from a [Produces] attribute. + if (contentType != null && parsedContentType.IsSubsetOf(parsedMediaType)) + { + if (mediaTypes == null) + { + mediaTypes = new List(); + } + + mediaTypes.Add(contentType); + } + } + else + { + // Confirm this formatter supports a more specific media type than requested e.g. OK if "text/*" + // requested and formatter supports "text/plain". Treat contentType like it came from an Accept header. + if (contentType == null || parsedMediaType.IsSubsetOf(parsedContentType)) { if (mediaTypes == null) { @@ -76,9 +87,9 @@ namespace Microsoft.AspNetCore.Mvc.Formatters mediaTypes.Add(mediaType); } } - - return mediaTypes; } + + return mediaTypes; } /// @@ -112,17 +123,36 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } else { - // Confirm this formatter supports a more specific media type than requested e.g. OK if "text/*" - // requested and formatter supports "text/plain". contentType is typically what we got in an Accept - // header. + // Confirm this formatter var parsedContentType = new MediaType(context.ContentType); for (var i = 0; i < SupportedMediaTypes.Count; i++) { var supportedMediaType = new MediaType(SupportedMediaTypes[i]); - if (supportedMediaType.IsSubsetOf(parsedContentType)) + if (supportedMediaType.HasWildcard) { - context.ContentType = new StringSegment(SupportedMediaTypes[i]); - return true; + // For supported media types that are wildcard patterns, confirm that the requested + // media type satisfies the wildcard pattern (e.g., if "text/entity+json;v=2" requested + // and formatter supports "text/*+json"). + // We only do this when comparing against server-defined content types (e.g., those + // from [Produces] or Response.ContentType), otherwise we'd potentially be reflecting + // back arbitrary Accept header values. + if (context.ContentTypeIsServerDefined + && parsedContentType.IsSubsetOf(supportedMediaType)) + { + return true; + } + } + else + { + // For supported media types that are not wildcard patterns, confirm that this formatter + // supports a more specific media type than requested e.g. OK if "text/*" requested and + // formatter supports "text/plain". + // contentType is typically what we got in an Accept header. + if (supportedMediaType.IsSubsetOf(parsedContentType)) + { + context.ContentType = new StringSegment(SupportedMediaTypes[i]); + return true; + } } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ObjectResultExecutor.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ObjectResultExecutor.cs index ff06f6d483..fd7fff6aab 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ObjectResultExecutor.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ObjectResultExecutor.cs @@ -302,6 +302,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal foreach (var formatter in formatters) { formatterContext.ContentType = new StringSegment(); + formatterContext.ContentTypeIsServerDefined = false; if (formatter.CanWriteResult(formatterContext)) { return formatter; @@ -349,6 +350,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal { var mediaType = sortedAcceptHeaders[i]; formatterContext.ContentType = mediaType.MediaType; + formatterContext.ContentTypeIsServerDefined = false; for (var j = 0; j < formatters.Count; j++) { var formatter = formatters[j]; @@ -401,6 +403,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal foreach (var contentType in acceptableContentTypes) { formatterContext.ContentType = new StringSegment(contentType); + formatterContext.ContentTypeIsServerDefined = true; if (formatter.CanWriteResult(formatterContext)) { return formatter; @@ -446,6 +449,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal { var formatter = formatters[k]; formatterContext.ContentType = new StringSegment(possibleOutputContentTypes[j]); + formatterContext.ContentTypeIsServerDefined = true; if (formatter.CanWriteResult(formatterContext)) { return formatter; @@ -469,8 +473,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal { var contentType = contentTypes[i]; var parsedContentType = new MediaType(contentType); - if (parsedContentType.MatchesAllTypes || - parsedContentType.MatchesAllSubTypes) + if (parsedContentType.HasWildcard) { var message = Resources.FormatObjectResult_MatchAllContentType( contentType, diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs index 6c89fc036d..202591a1b6 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs @@ -126,8 +126,7 @@ namespace Microsoft.AspNetCore.Mvc foreach (var arg in completeArgs) { var contentType = new MediaType(arg); - if (contentType.MatchesAllTypes || - contentType.MatchesAllSubTypes) + if (contentType.HasWildcard) { throw new InvalidOperationException( Resources.FormatMatchAllContentTypeIsNotAllowed(arg)); diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/MediaTypeHeaderValues.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/MediaTypeHeaderValues.cs index 0b45cff678..d9fb986507 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/MediaTypeHeaderValues.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/MediaTypeHeaderValues.cs @@ -15,5 +15,8 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal public static readonly MediaTypeHeaderValue ApplicationJsonPatch = MediaTypeHeaderValue.Parse("application/json-patch+json").CopyAsReadOnly(); + + public static readonly MediaTypeHeaderValue ApplicationAnyJsonSyntax + = MediaTypeHeaderValue.Parse("application/*+json").CopyAsReadOnly(); } } diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/MvcJsonMvcOptionsSetup.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/MvcJsonMvcOptionsSetup.cs index f924fd5c59..d588f9a79d 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/MvcJsonMvcOptionsSetup.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/MvcJsonMvcOptionsSetup.cs @@ -59,16 +59,19 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Json.Internal { options.OutputFormatters.Add(new JsonOutputFormatter(_jsonSerializerSettings, _charPool)); - var jsonInputLogger = _loggerFactory.CreateLogger(); - options.InputFormatters.Add(new JsonInputFormatter( - jsonInputLogger, + // Register JsonPatchInputFormatter before JsonInputFormatter, otherwise + // JsonInputFormatter would consume "application/json-patch+json" requests + // before JsonPatchInputFormatter gets to see them. + var jsonInputPatchLogger = _loggerFactory.CreateLogger(); + options.InputFormatters.Add(new JsonPatchInputFormatter( + jsonInputPatchLogger, _jsonSerializerSettings, _charPool, _objectPoolProvider)); - var jsonInputPatchLogger = _loggerFactory.CreateLogger(); - options.InputFormatters.Add(new JsonPatchInputFormatter( - jsonInputPatchLogger, + var jsonInputLogger = _loggerFactory.CreateLogger(); + options.InputFormatters.Add(new JsonInputFormatter( + jsonInputLogger, _jsonSerializerSettings, _charPool, _objectPoolProvider)); diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonInputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonInputFormatter.cs index aed8f35194..e146e7cce1 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonInputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonInputFormatter.cs @@ -70,6 +70,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationJson); SupportedMediaTypes.Add(MediaTypeHeaderValues.TextJson); + SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationAnyJsonSyntax); } /// diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonOutputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonOutputFormatter.cs index 98bbe2e0af..8ecdebb2e7 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonOutputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonOutputFormatter.cs @@ -50,6 +50,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters SupportedEncodings.Add(Encoding.Unicode); SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationJson); SupportedMediaTypes.Add(MediaTypeHeaderValues.TextJson); + SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationAnyJsonSyntax); } /// diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MediaTypeHeaderValues.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MediaTypeHeaderValues.cs index a71c4e5fd6..14f2a99ce4 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MediaTypeHeaderValues.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Internal/MediaTypeHeaderValues.cs @@ -12,5 +12,8 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml.Internal public static readonly MediaTypeHeaderValue TextXml = MediaTypeHeaderValue.Parse("text/xml").CopyAsReadOnly(); + + public static readonly MediaTypeHeaderValue ApplicationAnyXmlSyntax + = MediaTypeHeaderValue.Parse("application/*+xml").CopyAsReadOnly(); } } diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs index 73e6ba165b..6761a12ebc 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs @@ -35,6 +35,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationXml); SupportedMediaTypes.Add(MediaTypeHeaderValues.TextXml); + SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationAnyXmlSyntax); _serializerSettings = new DataContractSerializerSettings(); diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs index 17f1ea667f..8275092f83 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs @@ -48,6 +48,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationXml); SupportedMediaTypes.Add(MediaTypeHeaderValues.TextXml); + SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationAnyXmlSyntax); WriterSettings = writerSettings; diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs index 92e1eee58b..c8b7934724 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs @@ -34,6 +34,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationXml); SupportedMediaTypes.Add(MediaTypeHeaderValues.TextXml); + SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationAnyXmlSyntax); WrapperProviderFactories = new List(); WrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory()); diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs index 5adfc6be44..018eddda32 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs @@ -47,6 +47,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationXml); SupportedMediaTypes.Add(MediaTypeHeaderValues.TextXml); + SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationAnyXmlSyntax); WriterSettings = writerSettings; diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/MediaTypeTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/MediaTypeTest.cs index 95fa6c6a8d..1b6524085c 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/MediaTypeTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/MediaTypeTest.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.Text; using Microsoft.Extensions.Primitives; using Xunit; @@ -14,7 +15,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters [InlineData("application/json")] [InlineData("application /json")] [InlineData(" application / json ")] - public void Constructor_CanParseParameterlessMediaTypes(string mediaType) + public void Constructor_CanParseParameterlessSuffixlessMediaTypes(string mediaType) { // Arrange & Act var result = new MediaType(mediaType, 0, mediaType.Length); @@ -24,23 +25,54 @@ namespace Microsoft.AspNetCore.Mvc.Formatters Assert.Equal(new StringSegment("json"), result.SubType); } + public static IEnumerable MediaTypesWithSuffixes + { + get + { + return new List + { + // See https://tools.ietf.org/html/rfc6838#section-4.2 for allowed names spec + new[] { "application/json", "json", null }, + new[] { "application/json+", "json", "" }, + new[] { "application/+json", "", "json" }, + new[] { "application/entitytype+json", "entitytype", "json" }, + new[] { " application / vnd.com-pany.some+entity!.v2+js.#$&^_n ; q=\"0.3+1\"", "vnd.com-pany.some+entity!.v2", "js.#$&^_n" }, + }; + } + } + + [Theory] + [MemberData(nameof (MediaTypesWithSuffixes))] + public void Constructor_CanParseSuffixedMediaTypes( + string mediaType, + string expectedSubTypeWithoutSuffix, + string expectedSubtypeSuffix) + { + // Arrange & Act + var result = new MediaType(mediaType); + + // Assert + Assert.Equal(new StringSegment(expectedSubTypeWithoutSuffix), result.SubTypeWithoutSuffix); + Assert.Equal(new StringSegment(expectedSubtypeSuffix), result.SubTypeSuffix); + } + public static TheoryData MediaTypesWithParameters { get { return new TheoryData { - "application/json;format=pretty;charset=utf-8;q=0.8", - "application/json;format=pretty;charset=\"utf-8\";q=0.8", - "application/json;format=pretty;charset=utf-8; q=0.8 ", - "application/json;format=pretty;charset=utf-8 ; q=0.8 ", - "application/json;format=pretty; charset=utf-8 ; q=0.8 ", - "application/json;format=pretty ; charset=utf-8 ; q=0.8 ", - "application/json; format=pretty ; charset=utf-8 ; q=0.8 ", - "application/json; format=pretty ; charset=utf-8 ; q= 0.8 ", - "application/json; format=pretty ; charset=utf-8 ; q = 0.8 ", - " application / json; format = pretty ; charset = utf-8 ; q = 0.8 ", - " application / json; format = \"pretty\" ; charset = \"utf-8\" ; q = \"0.8\" ", + "application/json+bson;format=pretty;charset=utf-8;q=0.8", + "application/json+bson;format=pretty;charset=\"utf-8\";q=0.8", + "application/json+bson;format=pretty;charset=utf-8; q=0.8 ", + "application/json+bson;format=pretty;charset=utf-8 ; q=0.8 ", + "application/json+bson;format=pretty; charset=utf-8 ; q=0.8 ", + "application/json+bson;format=pretty ; charset=utf-8 ; q=0.8 ", + "application/json+bson; format=pretty ; charset=utf-8 ; q=0.8 ", + "application/json+bson; format=pretty ; charset=utf-8 ; q= 0.8 ", + "application/json+bson; format=pretty ; charset=utf-8 ; q = 0.8 ", + " application / json+bson; format = pretty ; charset = utf-8 ; q = 0.8 ", + " application / json+bson; format = \"pretty\" ; charset = \"utf-8\" ; q = \"0.8\" ", }; } } @@ -54,7 +86,9 @@ namespace Microsoft.AspNetCore.Mvc.Formatters // Assert Assert.Equal(new StringSegment("application"), result.Type); - Assert.Equal(new StringSegment("json"), result.SubType); + Assert.Equal(new StringSegment("json+bson"), result.SubType); + Assert.Equal(new StringSegment("json"), result.SubTypeWithoutSuffix); + Assert.Equal(new StringSegment("bson"), result.SubTypeSuffix); Assert.Equal(new StringSegment("pretty"), result.GetParameter("format")); Assert.Equal(new StringSegment("0.8"), result.GetParameter("q")); Assert.Equal(new StringSegment("utf-8"), result.GetParameter("charset")); @@ -171,6 +205,15 @@ namespace Microsoft.AspNetCore.Mvc.Formatters [InlineData("application/json", "application/json;format=indent;charset=utf-8")] [InlineData("application/json;format=indent;charset=utf-8", "application/json;format=indent;charset=utf-8")] [InlineData("application/json;charset=utf-8;format=indent", "application/json;format=indent;charset=utf-8")] + [InlineData("application/*", "application/json")] + [InlineData("application/*", "application/entitytype+json;v=2")] + [InlineData("application/*;v=2", "application/entitytype+json;v=2")] + [InlineData("application/json;*", "application/json;v=2")] + [InlineData("application/json;v=2;*", "application/json;v=2;charset=utf-8")] + [InlineData("*/*", "application/json")] + [InlineData("application/entity+json", "application/entity+json")] + [InlineData("application/*+json", "application/entity+json")] + [InlineData("application/*", "application/entity+json")] public void IsSubsetOf_ReturnsTrueWhenExpected(string set, string subset) { // Arrange @@ -188,6 +231,17 @@ namespace Microsoft.AspNetCore.Mvc.Formatters [InlineData("application/json;charset=utf-8", "application/json")] [InlineData("application/json;format=indent;charset=utf-8", "application/json")] [InlineData("application/json;format=indent;charset=utf-8", "application/json;charset=utf-8")] + [InlineData("application/*", "text/json")] + [InlineData("application/*;v=2", "application/json")] + [InlineData("application/*;v=2", "application/json;v=1")] + [InlineData("application/json;v=2;*", "application/json;v=1")] + [InlineData("application/entity+json", "application/entity+txt")] + [InlineData("application/entity+json", "application/entity.v2+json")] + [InlineData("application/*+json", "application/entity+txt")] + [InlineData("application/entity+*", "application/entity.v2+json")] + [InlineData("application/*+*", "application/json")] + [InlineData("application/entity+*", "application/entity+json")] // We don't allow suffixes to be wildcards + [InlineData("application/*+*", "application/entity+json")] // We don't allow suffixes to be wildcards public void IsSubsetOf_ReturnsFalseWhenExpected(string set, string subset) { // Arrange @@ -257,6 +311,48 @@ namespace Microsoft.AspNetCore.Mvc.Formatters Assert.False(result); } + [Theory] + [InlineData("*/*", true)] + [InlineData("text/*", true)] + [InlineData("text/*+suffix", true)] + [InlineData("text/*+", true)] + [InlineData("text/*+*", true)] + [InlineData("text/json+suffix", false)] + [InlineData("*/json+*", false)] + public void MatchesAllSubTypesWithoutSuffix_ReturnsExpectedResult(string value, bool expectedReturnValue) + { + // Arrange + var mediaType = new MediaType(value); + + // Act + var result = mediaType.MatchesAllSubTypesWithoutSuffix; + + // Assert + Assert.Equal(expectedReturnValue, result); + } + + [Theory] + [InlineData("*/*", true)] + [InlineData("text/*", true)] + [InlineData("text/entity+*", false)] // We don't support wildcards on suffixes + [InlineData("text/*+json", true)] + [InlineData("text/entity+json;*", true)] + [InlineData("text/entity+json;v=3;*", true)] + [InlineData("text/entity+json;v=3;q=0.8", false)] + [InlineData("text/json", false)] + [InlineData("text/json;param=*", false)] // * is the literal value of the param + public void HasWildcard_ReturnsTrueWhenExpected(string value, bool expectedReturnValue) + { + // Arrange + var mediaType = new MediaType(value); + + // Act + var result = mediaType.HasWildcard; + + // Assert + Assert.Equal(expectedReturnValue, result); + } + [Theory] [MemberData(nameof(MediaTypesWithParameters))] [InlineData("application/json;format=pretty;q=0.9;charset=utf-8;q=0.8")] diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/OutputFormatterTests.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/OutputFormatterTests.cs index eeb6f3ea6e..3c2e9b80a9 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/OutputFormatterTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/OutputFormatterTests.cs @@ -75,13 +75,47 @@ namespace Microsoft.AspNetCore.Mvc.Formatters Assert.False(result); } + [Theory] + [InlineData(true, true)] + [InlineData(false, false)] + public void CanWriteResult_MatchesWildcardsOnlyWhenContentTypeProvidedByServer( + bool contentTypeProvidedByServer, bool shouldMatchWildcards) + { + // Arrange + var formatter = new TypeSpecificFormatter(); + formatter.SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/*+xml")); + formatter.SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/*+json")); + formatter.SupportedTypes.Add(typeof(string)); + + var requestedContentType = "application/vnd.test.entity+json;v=2"; + var context = new OutputFormatterWriteContext( + new DefaultHttpContext(), + new TestHttpResponseStreamWriterFactory().CreateWriter, + typeof(string), + "Hello, world!") + { + ContentType = new StringSegment(requestedContentType), + ContentTypeIsServerDefined = contentTypeProvidedByServer, + }; + + // Act + var result = formatter.CanWriteResult(context); + + // Assert + Assert.Equal(shouldMatchWildcards, result); + Assert.Equal(requestedContentType, context.ContentType.ToString()); + } + [Fact] - public void GetSupportedContentTypes_ReturnsAllContentTypes_WithContentTypeNull() + public void GetSupportedContentTypes_ReturnsAllNonWildcardContentTypes_WithContentTypeNull() { // Arrange var formatter = new TestOutputFormatter(); formatter.SupportedMediaTypes.Clear(); formatter.SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json")); + formatter.SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("*/*")); + formatter.SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/*")); + formatter.SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/plain;*")); formatter.SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/xml")); // Act @@ -96,7 +130,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } [Fact] - public void GetSupportedContentTypes_ReturnsMatchingContentTypes_WithContentType() + public void GetSupportedContentTypes_ReturnsMoreSpecificMatchingContentTypes_WithContentType() { // Arrange var formatter = new TestOutputFormatter(); @@ -116,18 +150,39 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } [Fact] - public void GetSupportedContentTypes_ReturnsMatchingContentTypes_NoMatches() + public void GetSupportedContentTypes_ReturnsMatchingWildcardContentTypes_WithContentType() { // Arrange var formatter = new TestOutputFormatter(); formatter.SupportedMediaTypes.Clear(); formatter.SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json")); + formatter.SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/*+json")); formatter.SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/xml")); // Act var contentTypes = formatter.GetSupportedContentTypes( - "application/xml", + "application/vnd.test+json;v=2", + typeof(int)); + + // Assert + var contentType = Assert.Single(contentTypes); + Assert.Equal("application/vnd.test+json;v=2", contentType.ToString()); + } + + [Fact] + public void GetSupportedContentTypes_ReturnsMatchingContentTypes_NoMatches() + { + // Arrange + var formatter = new TestOutputFormatter(); + + formatter.SupportedMediaTypes.Clear(); + formatter.SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/*+xml")); + formatter.SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/xml")); + + // Act + var contentTypes = formatter.GetSupportedContentTypes( + "application/vnd.test+bson", typeof(int)); // Assert diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/ObjectResultExecutorTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/ObjectResultExecutorTest.cs index 8f055dc501..e7482d404f 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/ObjectResultExecutorTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/ObjectResultExecutorTest.cs @@ -342,6 +342,100 @@ namespace Microsoft.AspNetCore.Mvc.Internal Assert.Null(formatter); } + [Fact] + public void SelectFormatter_WithAcceptHeaderOnly_SetsContentTypeIsServerDefinedToFalse() + { + // Arrange + var executor = CreateExecutor(); + + var formatters = new List + { + new ServerContentTypeOnlyFormatter() + }; + + var context = new OutputFormatterWriteContext( + new DefaultHttpContext(), + new TestHttpResponseStreamWriterFactory().CreateWriter, + objectType: null, + @object: null); + + context.HttpContext.Request.Headers[HeaderNames.Accept] = "text/custom"; + + // Act + var formatter = executor.SelectFormatter( + context, + new MediaTypeCollection { }, + formatters); + + // Assert + Assert.Null(formatter); + } + + [Fact] + public void SelectFormatter_WithAcceptHeaderAndContentTypes_SetsContentTypeIsServerDefinedWhenExpected() + { + // Arrange + var executor = CreateExecutor(); + + var formatters = new List + { + new ServerContentTypeOnlyFormatter() + }; + + var context = new OutputFormatterWriteContext( + new DefaultHttpContext(), + new TestHttpResponseStreamWriterFactory().CreateWriter, + objectType: null, + @object: null); + + context.HttpContext.Request.Headers[HeaderNames.Accept] = "text/custom, text/custom2"; + + var serverDefinedContentTypes = new MediaTypeCollection(); + serverDefinedContentTypes.Add("text/other"); + serverDefinedContentTypes.Add("text/custom2"); + + // Act + var formatter = executor.SelectFormatter( + context, + serverDefinedContentTypes, + formatters); + + // Assert + Assert.Same(formatters[0], formatter); + Assert.Equal(new StringSegment("text/custom2"), context.ContentType); + } + + [Fact] + public void SelectFormatter_WithContentTypesOnly_SetsContentTypeIsServerDefinedToTrue() + { + // Arrange + var executor = CreateExecutor(); + + var formatters = new List + { + new ServerContentTypeOnlyFormatter() + }; + + var context = new OutputFormatterWriteContext( + new DefaultHttpContext(), + new TestHttpResponseStreamWriterFactory().CreateWriter, + objectType: null, + @object: null); + + var serverDefinedContentTypes = new MediaTypeCollection(); + serverDefinedContentTypes.Add("text/custom"); + + // Act + var formatter = executor.SelectFormatter( + context, + serverDefinedContentTypes, + formatters); + + // Assert + Assert.Same(formatters[0], formatter); + Assert.Equal(new StringSegment("text/custom"), context.ContentType); + } + [Fact] public async Task ExecuteAsync_NoFormatterFound_Returns406() { @@ -419,6 +513,8 @@ namespace Microsoft.AspNetCore.Mvc.Internal [InlineData(new[] { "*/*" }, "*/*")] [InlineData(new[] { "application/xml", "*/*", "application/json" }, "*/*")] [InlineData(new[] { "*/*", "application/json" }, "*/*")] + [InlineData(new[] { "application/json", "application/*+json" }, "application/*+json")] + [InlineData(new[] { "application/entiy+json;*", "application/json" }, "application/entiy+json;*")] public async Task ExecuteAsync_MatchAllContentType_Throws(string[] contentTypes, string invalidContentType) { // Arrange @@ -656,5 +752,21 @@ namespace Microsoft.AspNetCore.Mvc.Internal return SelectedOutputFormatter; } } + + private class ServerContentTypeOnlyFormatter : OutputFormatter + { + public override bool CanWriteResult(OutputFormatterCanWriteContext context) + { + // This test formatter matches if and only if the content type is specified + // as "server defined". This lets tests identify what value the ObjectResultExecutor + // passed for that flag. + return context.ContentTypeIsServerDefined; + } + + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context) + { + return Task.FromResult(0); + } + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ProducesAttributeTests.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ProducesAttributeTests.cs index 44dc11e471..3488442798 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ProducesAttributeTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ProducesAttributeTests.cs @@ -116,6 +116,8 @@ namespace Microsoft.AspNetCore.Mvc.Test [InlineData("*/*", "*/*")] [InlineData("application/xml, */*, application/json", "*/*")] [InlineData("*/*, application/json", "*/*")] + [InlineData("application/*+json", "application/*+json")] + [InlineData("application/json;v=1;*", "application/json;v=1;*")] public void ProducesAttribute_InvalidContentType_Throws(string content, string invalidContentType) { // Act diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs index 8e5530831e..75dd819910 100644 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs @@ -33,6 +33,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters [InlineData("text/*", false)] [InlineData("text/xml", false)] [InlineData("application/xml", false)] + [InlineData("application/some.entity+json", true)] + [InlineData("application/some.entity+json;v=2", true)] + [InlineData("application/some.entity+xml", false)] + [InlineData("application/some.entity+*", false)] + [InlineData("text/some.entity+json", false)] [InlineData("", false)] [InlineData(null, false)] [InlineData("invalid", false)] diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonOutputFormatterTests.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonOutputFormatterTests.cs index b71ed5a3a2..53e52f677a 100644 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonOutputFormatterTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonOutputFormatterTests.cs @@ -401,6 +401,49 @@ namespace Microsoft.AspNetCore.Mvc.Formatters Assert.Equal(expectedOutput, content); } + [Theory] + [InlineData("application/json", false, "application/json")] + [InlineData("application/json", true, "application/json")] + [InlineData("application/xml", false, null)] + [InlineData("application/xml", true, null)] + [InlineData("application/*", false, "application/json")] + [InlineData("text/*", false, "text/json")] + [InlineData("custom/*", false, null)] + [InlineData("application/json;v=2", false, null)] + [InlineData("application/json;v=2", true, null)] + [InlineData("application/some.entity+json", false, null)] + [InlineData("application/some.entity+json", true, "application/some.entity+json")] + [InlineData("application/some.entity+json;v=2", true, "application/some.entity+json;v=2")] + [InlineData("application/some.entity+xml", true, null)] + public void CanWriteResult_ReturnsExpectedValueForMediaType( + string mediaType, + bool isServerDefined, + string expectedResult) + { + // Arrange + var formatter = new JsonOutputFormatter(new JsonSerializerSettings(), ArrayPool.Shared); + + var body = new MemoryStream(); + var actionContext = GetActionContext(MediaTypeHeaderValue.Parse(mediaType), body); + var outputFormatterContext = new OutputFormatterWriteContext( + actionContext.HttpContext, + new TestHttpResponseStreamWriterFactory().CreateWriter, + typeof(string), + new object()) + { + ContentType = new StringSegment(mediaType), + ContentTypeIsServerDefined = isServerDefined, + }; + + // Act + var actualCanWriteValue = formatter.CanWriteResult(outputFormatterContext); + + // Assert + var expectedContentType = expectedResult ?? mediaType; + Assert.Equal(expectedResult != null, actualCanWriteValue); + Assert.Equal(new StringSegment(expectedContentType), outputFormatterContext.ContentType); + } + private static Encoding CreateOrGetSupportedEncoding( JsonOutputFormatter formatter, string encodingAsString, diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs index 4168428d89..9c99d85692 100644 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerInputFormatterTest.cs @@ -58,6 +58,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml [InlineData("text/*", false)] [InlineData("text/json", false)] [InlineData("application/json", false)] + [InlineData("application/some.entity+xml", true)] + [InlineData("application/some.entity+xml;v=2", true)] + [InlineData("application/some.entity+json", false)] + [InlineData("application/some.entity+*", false)] + [InlineData("text/some.entity+json", false)] [InlineData("", false)] [InlineData(null, false)] [InlineData("invalid", false)] diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerOutputFormatterTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerOutputFormatterTest.cs index f83056d415..acabb49213 100644 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerOutputFormatterTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlDataContractSerializerOutputFormatterTest.cs @@ -377,7 +377,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml // Mono issue - https://github.com/aspnet/External/issues/18 [FrameworkSkipCondition(RuntimeFrameworks.Mono)] [MemberData(nameof(TypesForCanWriteResult))] - public void CanWriteResult_ReturnsExpectedOutput(object input, Type declaredType, bool expectedOutput) + public void CanWriteResult_ReturnsExpectedValueForObjectType(object input, Type declaredType, bool expectedOutput) { // Arrange var formatter = new XmlDataContractSerializerOutputFormatter(); @@ -391,6 +391,42 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml Assert.Equal(expectedOutput, result); } + [ConditionalTheory] + // Mono issue - https://github.com/aspnet/External/issues/18 + [FrameworkSkipCondition(RuntimeFrameworks.Mono)] + [InlineData("application/xml", false, "application/xml")] + [InlineData("application/xml", true, "application/xml")] + [InlineData("application/other", false, null)] + [InlineData("application/other", true, null)] + [InlineData("application/*", false, "application/xml")] + [InlineData("text/*", false, "text/xml")] + [InlineData("custom/*", false, null)] + [InlineData("application/xml;v=2", false, null)] + [InlineData("application/xml;v=2", true, null)] + [InlineData("application/some.entity+xml", false, null)] + [InlineData("application/some.entity+xml", true, "application/some.entity+xml")] + [InlineData("application/some.entity+xml;v=2", true, "application/some.entity+xml;v=2")] + [InlineData("application/some.entity+other", true, null)] + public void CanWriteResult_ReturnsExpectedValueForMediaType( + string mediaType, + bool isServerDefined, + string expectedResult) + { + // Arrange + var formatter = new XmlDataContractSerializerOutputFormatter(); + var outputFormatterContext = GetOutputFormatterContext(new object(), typeof(object)); + outputFormatterContext.ContentType = new StringSegment(mediaType); + outputFormatterContext.ContentTypeIsServerDefined = isServerDefined; + + // Act + var actualCanWriteValue = formatter.CanWriteResult(outputFormatterContext); + + // Assert + var expectedContentType = expectedResult ?? mediaType; + Assert.Equal(expectedResult != null, actualCanWriteValue); + Assert.Equal(new StringSegment(expectedContentType), outputFormatterContext.ContentType); + } + public static IEnumerable TypesForGetSupportedContentTypes { get diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerInputFormatterTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerInputFormatterTest.cs index 8f1400cd0a..57fad1d062 100644 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerInputFormatterTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerInputFormatterTest.cs @@ -48,6 +48,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml [InlineData("text/*", false)] [InlineData("text/json", false)] [InlineData("application/json", false)] + [InlineData("application/some.entity+xml", true)] + [InlineData("application/some.entity+xml;v=2", true)] + [InlineData("application/some.entity+json", false)] + [InlineData("application/some.entity+*", false)] + [InlineData("text/some.entity+json", false)] [InlineData("", false)] [InlineData("invalid", false)] [InlineData(null, false)] diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerOutputFormatterTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerOutputFormatterTest.cs index a2b8d2d87c..6a36e90284 100644 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerOutputFormatterTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/XmlSerializerOutputFormatterTest.cs @@ -295,7 +295,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml [Theory] [MemberData(nameof(TypesForCanWriteResult))] - public void XmlSerializer_CanWriteResult(object input, Type declaredType, bool expectedOutput) + public void CanWriteResult_ReturnsExpectedValueForObjectType(object input, Type declaredType, bool expectedOutput) { // Arrange var formatter = new XmlSerializerOutputFormatter(); @@ -309,6 +309,40 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml Assert.Equal(expectedOutput, result); } + [Theory] + [InlineData("application/xml", false, "application/xml")] + [InlineData("application/xml", true, "application/xml")] + [InlineData("application/other", false, null)] + [InlineData("application/other", true, null)] + [InlineData("application/*", false, "application/xml")] + [InlineData("text/*", false, "text/xml")] + [InlineData("custom/*", false, null)] + [InlineData("application/xml;v=2", false, null)] + [InlineData("application/xml;v=2", true, null)] + [InlineData("application/some.entity+xml", false, null)] + [InlineData("application/some.entity+xml", true, "application/some.entity+xml")] + [InlineData("application/some.entity+xml;v=2", true, "application/some.entity+xml;v=2")] + [InlineData("application/some.entity+other", true, null)] + public void CanWriteResult_ReturnsExpectedValueForMediaType( + string mediaType, + bool isServerDefined, + string expectedResult) + { + // Arrange + var formatter = new XmlSerializerOutputFormatter(); + var outputFormatterContext = GetOutputFormatterContext(new object(), typeof (object)); + outputFormatterContext.ContentType = new StringSegment(mediaType); + outputFormatterContext.ContentTypeIsServerDefined = isServerDefined; + + // Act + var actualCanWriteValue = formatter.CanWriteResult(outputFormatterContext); + + // Assert + var expectedContentType = expectedResult ?? mediaType; + Assert.Equal(expectedResult != null, actualCanWriteValue); + Assert.Equal(new StringSegment(expectedContentType), outputFormatterContext.ContentType); + } + [Fact] public async Task XmlSerializerOutputFormatterDoesntFlushOutputStream() { diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs index 820509ba4b..c83ac821dd 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs @@ -817,6 +817,25 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal(typeof(JsonOutputFormatter).FullName, textJson.FormatterType); } + [Fact] + public async Task ApiExplorer_ResponseContentType_WildcardMatch() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/ApiExplorerResponseContentType/WildcardMatch"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(1, responseType.ResponseFormats.Count); + + var responseFormat = responseType.ResponseFormats[0]; + Assert.Equal("application/hal+json", responseFormat.MediaType); + Assert.Equal(typeof(JsonOutputFormatter).FullName, responseFormat.FormatterType); + } + [Fact] public async Task ApiExplorer_ResponseContentType_NoMatch() { diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTests.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTests.cs index 18e162e3d6..979a0a7bf2 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTests.cs @@ -123,5 +123,47 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(expectedString, product.SampleString); } + + [Fact] + public async Task JsonSyntaxSuffix_SelectsActionConsumingJson() + { + // Arrange + var input = "{SampleString:\"some input\"}"; + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_MediaTypeSuffix/CreateProduct"); + request.Content = new StringContent(input, Encoding.UTF8, "application/vnd.example+json"); + + // Act + var response = await Client.SendAsync(request); + var product = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Read from JSON: some input", product.SampleString); + } + + [ConditionalFact] + // Mono issue - https://github.com/aspnet/External/issues/18 + [FrameworkSkipCondition(RuntimeFrameworks.Mono)] + public async Task XmlSyntaxSuffix_SelectsActionConsumingXml() + { + // Arrange + var input = "" + + "some input"; + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_MediaTypeSuffix/CreateProduct"); + request.Content = new StringContent(input, Encoding.UTF8, "application/vnd.example+xml"); + + // Act + var response = await Client.SendAsync(request); + var product = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Read from XML: some input", product.SampleString); + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ContentNegotiationTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ContentNegotiationTest.cs index 746e0c2b6d..18f5f8a5d9 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ContentNegotiationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ContentNegotiationTest.cs @@ -5,10 +5,13 @@ using System; using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Runtime.Serialization; using System.Text; using System.Threading.Tasks; +using BasicWebSite.Models; using Microsoft.AspNetCore.Mvc.Formatters.Xml; using Microsoft.AspNetCore.Testing.xunit; +using Newtonsoft.Json; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests @@ -468,5 +471,42 @@ END:VCARD var body = await response.Content.ReadAsStringAsync(); Assert.Equal(body, "MethodWithFormatFilter"); } + + [Fact] + public async Task ProducesAttribute_CustomMediaTypeWithJsonSuffix_RunsConnegAndSelectsJsonFormatter() + { + // Arrange + var expectedMediaType = MediaTypeHeaderValue.Parse("application/vnd.example.contact+json; v=2; charset=utf-8"); + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/ProducesWithMediaTypeSuffixesController/ContactInfo"); + request.Headers.Add("Accept", "application/vnd.example.contact+json; v=2"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(expectedMediaType, response.Content.Headers.ContentType); + var body = await response.Content.ReadAsStringAsync(); + var contact = JsonConvert.DeserializeObject(body); + Assert.Equal("Jason Ecsemelle", contact.Name); + } + + [Fact] + public async Task ProducesAttribute_CustomMediaTypeWithXmlSuffix_RunsConnegAndSelectsXmlFormatter() + { + // Arrange + var expectedMediaType = MediaTypeHeaderValue.Parse("application/vnd.example.contact+xml; v=2; charset=utf-8"); + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/ProducesWithMediaTypeSuffixesController/ContactInfo"); + request.Headers.Add("Accept", "application/vnd.example.contact+xml; v=2"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(expectedMediaType, response.Content.Headers.ContentType); + var bodyStream = await response.Content.ReadAsStreamAsync(); + var xmlDeserializer = new DataContractSerializer(typeof(Contact)); + var contact = xmlDeserializer.ReadObject(bodyStream) as Contact; + Assert.Equal("Jason Ecsemelle", contact.Name); + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs b/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs index 2b166e10db..011955e270 100644 --- a/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs @@ -106,8 +106,8 @@ namespace Microsoft.AspNetCore.Mvc // Assert Assert.Collection(options.InputFormatters, - formatter => Assert.IsType(formatter), - formatter => Assert.IsType(formatter)); + formatter => Assert.IsType(formatter), + formatter => Assert.IsType(formatter)); } [Fact] diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseContentTypeController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseContentTypeController.cs index d4aea83bc3..3a5d526954 100644 --- a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseContentTypeController.cs +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseContentTypeController.cs @@ -22,7 +22,14 @@ namespace ApiExplorerWebSite } [HttpGet] - [Produces("application/hal+json", "text/hal+json")] + [Produces("application/hal+custom", "application/hal+json")] + public Product WildcardMatch() + { + return null; + } + + [HttpGet] + [Produces("application/custom", "text/hal+bson")] public Product NoMatch() { return null; diff --git a/test/WebSites/BasicWebSite/Controllers/ActionConstraints/ConsumesAttribute_MediaTypeSuffix.cs b/test/WebSites/BasicWebSite/Controllers/ActionConstraints/ConsumesAttribute_MediaTypeSuffix.cs new file mode 100644 index 0000000000..a2cb0d6160 --- /dev/null +++ b/test/WebSites/BasicWebSite/Controllers/ActionConstraints/ConsumesAttribute_MediaTypeSuffix.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BasicWebSite.Models; +using Microsoft.AspNetCore.Mvc; + +namespace BasicWebSite.Controllers.ActionConstraints +{ + [Route("ConsumesAttribute_MediaTypeSuffix/[action]")] + public class ConsumesAttribute_MediaTypeSuffix : Controller + { + [Consumes("application/vnd.example+json")] + public Product CreateProduct([FromBody] Product_Json jsonInput) + { + // To show that we selected the correct method (and not just the + // correct input formatter), produce method-specific output. + jsonInput.SampleString = "Read from JSON: " + jsonInput.SampleString; + return jsonInput; + } + + [Consumes("application/vnd.example+xml")] + public Product CreateProduct([FromBody] Product_Xml xmlInput) + { + // To show that we selected the correct method (and not just the + // correct input formatter), produce method-specific output. + xmlInput.SampleString = "Read from XML: " + xmlInput.SampleString; + return xmlInput; + } + } +} diff --git a/test/WebSites/BasicWebSite/Controllers/ContentNegotiation/ProducesWithMediaTypeSuffixesController.cs b/test/WebSites/BasicWebSite/Controllers/ContentNegotiation/ProducesWithMediaTypeSuffixesController.cs new file mode 100644 index 0000000000..fcd9b973d9 --- /dev/null +++ b/test/WebSites/BasicWebSite/Controllers/ContentNegotiation/ProducesWithMediaTypeSuffixesController.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BasicWebSite.Models; +using Microsoft.AspNetCore.Mvc; + +namespace BasicWebSite.Controllers.ContentNegotiation +{ + [Route("ProducesWithMediaTypeSuffixesController/[action]")] + public class ProducesWithMediaTypeSuffixesController : Controller + { + [Produces("application/vnd.example.contact+json; v=2", "application/vnd.example.contact+xml; v=2")] + public Contact ContactInfo() + { + return new Contact() + { + Name = "Jason Ecsemelle" + }; + } + } +}