diff --git a/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/DeclaredApiResponseMetadata.cs b/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/DeclaredApiResponseMetadata.cs index a45b1ed573..30819931d0 100644 --- a/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/DeclaredApiResponseMetadata.cs +++ b/src/Microsoft.AspNetCore.Mvc.Api.Analyzers/DeclaredApiResponseMetadata.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Mvc.Api.Analyzers internal readonly struct DeclaredApiResponseMetadata { public static DeclaredApiResponseMetadata ImplicitResponse { get; } = - new DeclaredApiResponseMetadata(statusCode: 0, attributeData: null, attributeSource: null, @implicit: true, @default: false); + new DeclaredApiResponseMetadata(statusCode: 200, attributeData: null, attributeSource: null, @implicit: true, @default: false); public static DeclaredApiResponseMetadata ForProducesResponseType(int statusCode, AttributeData attributeData, IMethodSymbol attributeSource) { @@ -41,8 +41,16 @@ namespace Microsoft.AspNetCore.Mvc.Api.Analyzers public IMethodSymbol AttributeSource { get; } + /// + /// True if this is the implicit 200 associated with an + /// action specifying no metadata. + /// public bool IsImplicit { get; } + /// + /// True if this is from a ProducesDefaultResponseTypeAttribute. + /// Matches all failure (400 and above) status codes. + /// public bool IsDefault { get; } internal static bool Contains(IList declaredApiResponseMetadata, ActualApiResponseMetadata actualMetadata) diff --git a/test/Mvc.Api.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs b/test/Mvc.Api.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs index f611c8d421..5e68cdbe54 100644 --- a/test/Mvc.Api.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs +++ b/test/Mvc.Api.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs @@ -26,6 +26,10 @@ namespace Microsoft.AspNetCore.Mvc.Api.Analyzers public Task NoDiagnosticsAreReturned_ForApiController_WithAllDocumentedStatusCodes() => RunNoDiagnosticsAreReturned(); + [Fact] + public Task NoDiagnosticsAreReturned_ForOkResultReturningAction() + => RunNoDiagnosticsAreReturned(); + [Fact] public Task NoDiagnosticsAreReturned_ForApiController_IfStatusCodesCannotBeInferred() => RunNoDiagnosticsAreReturned(); diff --git a/test/Mvc.Api.Analyzers.Test/DeclaredApiResponseMetadataTest.cs b/test/Mvc.Api.Analyzers.Test/DeclaredApiResponseMetadataTest.cs new file mode 100644 index 0000000000..fcfc65ebef --- /dev/null +++ b/test/Mvc.Api.Analyzers.Test/DeclaredApiResponseMetadataTest.cs @@ -0,0 +1,169 @@ +// 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 System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + public class DeclaredApiResponseMetadataTest + { + private readonly ReturnStatementSyntax ReturnStatement = SyntaxFactory.ReturnStatement(); + private readonly AttributeData AttributeData = new TestAttributeData(); + + [Fact] + public void Matches_ReturnsTrue_IfDeclaredMetadataIsImplicit_AndActualMetadataIsDefaultResponse() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ImplicitResponse; + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.True(matches); + } + + [Fact] + public void Matches_ReturnsTrue_IfDeclaredMetadataIsImplicit_AndActualMetadataReturns200() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ImplicitResponse; + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement, 200); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.True(matches); + } + + [Fact] + public void Matches_ReturnsTrue_IfDeclaredMetadataIs200_AndActualMetadataIsDefaultResponse() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ForProducesResponseType(200, AttributeData, Mock.Of()); + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.True(matches); + } + + /// + /// [ProducesResponseType(201)] + /// public IActionResult SomeAction => new Model(); + /// + [Fact] + public void Matches_ReturnsTrue_IfDeclaredMetadataIs201_AndActualMetadataIsDefault() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ForProducesResponseType(201, AttributeData, Mock.Of()); + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.True(matches); + } + + /// + /// [ProducesResponseType(201)] + /// public IActionResult SomeAction => Ok(new Model()); + /// + [Fact] + public void Matches_ReturnsFalse_IfDeclaredMetadataIs201_AndActualMetadataIs200() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ForProducesResponseType(201, AttributeData, Mock.Of()); + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement, 200); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.False(matches); + } + + [Fact] + public void Matches_ReturnsTrue_IfDeclaredMetadataAndActualMetadataHaveSameStatusCode() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ForProducesResponseType(302, AttributeData, Mock.Of()); + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement, 302); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.True(matches); + } + + [Theory] + [InlineData(400)] + [InlineData(409)] + [InlineData(500)] + public void Matches_ReturnsTrue_IfDeclaredMetadataIsDefault_AndActualMetadataIsErrorStatusCode(int actualStatusCode) + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ForProducesDefaultResponse(AttributeData, Mock.Of()); + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement, actualStatusCode); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.True(matches); + } + + [Fact] + public void Matches_ReturnsFalse_IfDeclaredMetadataIsDefault_AndActualMetadataIsNotErrorStatusCode() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ForProducesDefaultResponse(AttributeData, Mock.Of()); + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement, 204); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.False(matches); + } + + [Fact] + public void Matches_ReturnsFalse_IfDeclaredMetadataIsDefault_AndActualMetadataIsDefaultResponse() + { + // Arrange + var declaredMetadata = DeclaredApiResponseMetadata.ForProducesDefaultResponse(AttributeData, Mock.Of()); + var actualMetadata = new ActualApiResponseMetadata(ReturnStatement); + + // Act + var matches = declaredMetadata.Matches(actualMetadata); + + // Assert + Assert.False(matches); + } + + private class TestAttributeData : AttributeData + { + protected override INamedTypeSymbol CommonAttributeClass => throw new System.NotImplementedException(); + + protected override IMethodSymbol CommonAttributeConstructor => throw new System.NotImplementedException(); + + protected override SyntaxReference CommonApplicationSyntaxReference => throw new System.NotImplementedException(); + + protected override ImmutableArray CommonConstructorArguments => throw new System.NotImplementedException(); + + protected override ImmutableArray> CommonNamedArguments => throw new System.NotImplementedException(); + } + } +} diff --git a/test/Mvc.Api.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs b/test/Mvc.Api.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs index 483901ffa1..c417a199fe 100644 --- a/test/Mvc.Api.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs +++ b/test/Mvc.Api.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs @@ -1,11 +1,13 @@ // 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 System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.SymbolApiResponseMetadataProviderTest; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using Xunit; @@ -433,6 +435,95 @@ namespace Microsoft.AspNetCore.Mvc.Api.Analyzers Assert.True(actualResponseMetadata.Value.IsDefaultResponse); } + [Fact] + public async Task TryGetActualResponseMetadata_ActionWithActionResultOfTReturningOkResult() + { + // Arrange + var typeName = typeof(TryGetActualResponseMetadataController).FullName; + var methodName = nameof(TryGetActualResponseMetadataController.ActionWithActionResultOfTReturningOkResult); + + // Act + var (success, responseMetadatas, _) = await TryGetActualResponseMetadata(typeName, methodName); + + // Assert + Assert.True(success); + Assert.Collection( + responseMetadatas, + metadata => + { + Assert.False(metadata.IsDefaultResponse); + Assert.Equal(200, metadata.StatusCode); + }); + } + + [Fact] + public async Task TryGetActualResponseMetadata_ActionWithActionResultOfTReturningModel() + { + // Arrange + var typeName = typeof(TryGetActualResponseMetadataController).FullName; + var methodName = nameof(TryGetActualResponseMetadataController.ActionWithActionResultOfTReturningModel); + + // Act + var (success, responseMetadatas, _) = await TryGetActualResponseMetadata(typeName, methodName); + + // Assert + Assert.True(success); + Assert.Collection( + responseMetadatas, + metadata => + { + Assert.True(metadata.IsDefaultResponse); + }); + } + + [Fact] + public async Task TryGetActualResponseMetadata_ActionReturningNotFoundAndModel() + { + // Arrange + var typeName = typeof(TryGetActualResponseMetadataController).FullName; + var methodName = nameof(TryGetActualResponseMetadataController.ActionReturningNotFoundAndModel); + + // Act + var (success, responseMetadatas, testSource) = await TryGetActualResponseMetadata(typeName, methodName); + + // Assert + Assert.True(success); + Assert.Collection( + responseMetadatas, + metadata => + { + Assert.False(metadata.IsDefaultResponse); + Assert.Equal(204, metadata.StatusCode); + AnalyzerAssert.DiagnosticLocation(testSource.MarkerLocations["MM1"], metadata.ReturnStatement.GetLocation()); + + }, + metadata => + { + Assert.True(metadata.IsDefaultResponse); + AnalyzerAssert.DiagnosticLocation(testSource.MarkerLocations["MM2"], metadata.ReturnStatement.GetLocation()); + }); + } + + private async Task<(bool result, IList responseMetadatas, TestSource testSource)> TryGetActualResponseMetadata(string typeName, string methodName) + { + var testSource = MvcTestSource.Read(GetType().Name, "TryGetActualResponseMetadataTests"); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + var compilation = await GetCompilation("TryGetActualResponseMetadataTests"); + + var type = compilation.GetTypeByMetadataName(typeName); + var method = (IMethodSymbol)type.GetMembers(methodName).First(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var syntaxTree = method.DeclaringSyntaxReferences[0].SyntaxTree; + var methodSyntax = (MethodDeclarationSyntax)syntaxTree.GetRoot().FindNode(method.Locations[0].SourceSpan); + var semanticModel = compilation.GetSemanticModel(syntaxTree); + + var result = SymbolApiResponseMetadataProvider.TryGetActualResponseMetadata(symbolCache, semanticModel, methodSyntax, CancellationToken.None, out var responseMetadatas); + + return (result, responseMetadatas, testSource); + } + private async Task RunInspectReturnStatementSyntax([CallerMemberName]string test = null) { // Arrange diff --git a/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForOkResultReturningAction.cs b/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForOkResultReturningAction.cs new file mode 100644 index 0000000000..e5ae8feb2c --- /dev/null +++ b/test/Mvc.Api.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForOkResultReturningAction.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers +{ + [ApiController] + public class NoDiagnosticsAreReturned_ForOkResultReturningAction : ControllerBase + { + public async Task>> Action() + { + await Task.Yield(); + var models = new List(); + + return Ok(models); + } + } + + public class NoDiagnosticsAreReturned_ForOkResultReturningActionModel { } +} diff --git a/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/TryGetActualResponseMetadataTests.cs b/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/TryGetActualResponseMetadataTests.cs new file mode 100644 index 0000000000..38e4bc1a81 --- /dev/null +++ b/test/Mvc.Api.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/TryGetActualResponseMetadataTests.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Mvc.Api.Analyzers.TestFiles.SymbolApiResponseMetadataProviderTest +{ + public class TryGetActualResponseMetadataController : ControllerBase + { + public async Task>> ActionWithActionResultOfTReturningOkResult() + { + await Task.Yield(); + var models = new List(); + + return Ok(models); + } + + public async Task>> ActionWithActionResultOfTReturningModel() + { + await Task.Yield(); + var models = new List(); + + return models; + } + + public async Task> ActionReturningNotFoundAndModel(int id) + { + await Task.Yield(); + + if (id == 0) + { + /*MM1*/return NoContent(); + } + + /*MM2*/return new TryGetActualResponseMetadataModel(); + } + } + + public class TryGetActualResponseMetadataModel { } +}