Ver código fonte

[release/7.0] Add query parameters to gRPC transcoding OpenAPI (#43751)

* [release/7.0] Add query parameters to gRPC transcoding OpenAPI

* Use JSON name in route parameters

* Comment
James Newton-King 3 anos atrás
pai
commit
0c90eea916
19 arquivos alterados com 559 adições e 113 exclusões
  1. 1 1
      src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/JsonTranscodingProviderServiceBinder.cs
  2. 3 2
      src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/CallHandlerDescriptorInfo.cs
  3. 1 1
      src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonRequestHelpers.cs
  4. 51 4
      src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcJsonTranscodingDescriptionProvider.cs
  5. 0 1
      src/Grpc/JsonTranscoding/src/Shared/HttpRoutePattern.cs
  6. 106 10
      src/Grpc/JsonTranscoding/src/Shared/ServiceDescriptorHelpers.cs
  7. 2 2
      src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/TestHelpers.cs
  8. 2 2
      src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/TestHelpers.cs
  9. 1 1
      src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/JsonTranscodingServerCallContextTests.cs
  10. 17 10
      src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ServerStreamingServerCallHandlerTests.cs
  11. 13 8
      src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/UnaryServerCallHandlerTests.cs
  12. 1 11
      src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/GrpcSwaggerServiceExtensionsTests.cs
  13. 49 0
      src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Infrastructure/OpenApiTestHelpers.cs
  14. 17 0
      src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Infrastructure/TestWebHostEnvironment.cs
  15. 2 1
      src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Microsoft.AspNetCore.Grpc.Swagger.Tests.csproj
  16. 141 0
      src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Parameters/ParametersTests.cs
  17. 112 0
      src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/parameters.proto
  18. 30 0
      src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/ParametersService.cs
  19. 10 59
      src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/XmlComments/XmlDocumentationIntegrationTests.cs

+ 1 - 1
src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/JsonTranscodingProviderServiceBinder.cs

@@ -237,7 +237,7 @@ internal sealed partial class JsonTranscodingProviderServiceBinder<TService> : S
 
     private static CallHandlerDescriptorInfo CreateDescriptorInfo(string body, string responseBody, MethodDescriptor methodDescriptor, JsonTranscodingRouteAdapter routeAdapter)
     {
-        var routeParameterDescriptors = ServiceDescriptorHelpers.ResolveRouteParameterDescriptors(routeAdapter.HttpRoutePattern.Variables.Select(v => v.FieldPath).ToList(), methodDescriptor.InputType);
+        var routeParameterDescriptors = ServiceDescriptorHelpers.ResolveRouteParameterDescriptors(routeAdapter.HttpRoutePattern.Variables, methodDescriptor.InputType);
 
         var bodyDescriptor = ServiceDescriptorHelpers.ResolveBodyDescriptor(body, typeof(TService), methodDescriptor);
 

+ 3 - 2
src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/CallHandlerDescriptorInfo.cs

@@ -5,6 +5,7 @@ using System.Collections.Concurrent;
 using System.Diagnostics.CodeAnalysis;
 using System.Linq;
 using Google.Protobuf.Reflection;
+using Grpc.Shared;
 
 namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers;
 
@@ -15,7 +16,7 @@ internal sealed class CallHandlerDescriptorInfo
         MessageDescriptor? bodyDescriptor,
         bool bodyDescriptorRepeated,
         List<FieldDescriptor>? bodyFieldDescriptors,
-        Dictionary<string, List<FieldDescriptor>> routeParameterDescriptors,
+        Dictionary<string, RouteParameter> routeParameterDescriptors,
         JsonTranscodingRouteAdapter routeAdapter)
     {
         ResponseBodyDescriptor = responseBodyDescriptor;
@@ -36,7 +37,7 @@ internal sealed class CallHandlerDescriptorInfo
     [MemberNotNullWhen(true, nameof(BodyFieldDescriptors), nameof(BodyFieldDescriptorsPath))]
     public bool BodyDescriptorRepeated { get; }
     public List<FieldDescriptor>? BodyFieldDescriptors { get; }
-    public Dictionary<string, List<FieldDescriptor>> RouteParameterDescriptors { get; }
+    public Dictionary<string, RouteParameter> RouteParameterDescriptors { get; }
     public JsonTranscodingRouteAdapter RouteAdapter { get; }
     public ConcurrentDictionary<string, List<FieldDescriptor>?> PathDescriptorsCache { get; }
     public string? BodyFieldDescriptorsPath { get; }

+ 1 - 1
src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonRequestHelpers.cs

@@ -256,7 +256,7 @@ internal static class JsonRequestHelpers
                 var routeValue = serverCallContext.HttpContext.Request.RouteValues[parameterDescriptor.Key];
                 if (routeValue != null)
                 {
-                    ServiceDescriptorHelpers.RecursiveSetValue(requestMessage, parameterDescriptor.Value, routeValue);
+                    ServiceDescriptorHelpers.RecursiveSetValue(requestMessage, parameterDescriptor.Value.DescriptorsPath, routeValue);
                 }
             }
 

+ 51 - 4
src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcJsonTranscodingDescriptionProvider.cs

@@ -2,6 +2,7 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System.Linq;
+using System.Text;
 using Google.Api;
 using Google.Protobuf.Reflection;
 using Grpc.AspNetCore.Server;
@@ -69,7 +70,6 @@ internal sealed class GrpcJsonTranscodingDescriptionProvider : IApiDescriptionPr
             },
             EndpointMetadata = routeEndpoint.Metadata.ToList()
         };
-        apiDescription.RelativePath = pattern.TrimStart('/');
         apiDescription.SupportedRequestFormats.Add(new ApiRequestFormat { MediaType = "application/json" });
         apiDescription.SupportedResponseTypes.Add(new ApiResponseType
         {
@@ -91,11 +91,13 @@ internal sealed class GrpcJsonTranscodingDescriptionProvider : IApiDescriptionPr
 
         var methodMetadata = routeEndpoint.Metadata.GetMetadata<GrpcMethodMetadata>()!;
         var httpRoutePattern = HttpRoutePattern.Parse(pattern);
-        var routeParameters = ServiceDescriptorHelpers.ResolveRouteParameterDescriptors(httpRoutePattern.Variables.Select(v => v.FieldPath).ToList(), methodDescriptor.InputType);
+        var routeParameters = ServiceDescriptorHelpers.ResolveRouteParameterDescriptors(httpRoutePattern.Variables, methodDescriptor.InputType);
+
+        apiDescription.RelativePath = ResolvePath(httpRoutePattern, routeParameters);
 
         foreach (var routeParameter in routeParameters)
         {
-            var field = routeParameter.Value.Last();
+            var field = routeParameter.Value.DescriptorsPath.Last();
             var parameterName = ServiceDescriptorHelpers.FormatUnderscoreName(field.Name, pascalCase: true, preservePeriod: false);
             var propertyInfo = field.ContainingType.ClrType.GetProperty(parameterName);
 
@@ -106,7 +108,7 @@ internal sealed class GrpcJsonTranscodingDescriptionProvider : IApiDescriptionPr
 
             apiDescription.ParameterDescriptions.Add(new ApiParameterDescription
             {
-                Name = routeParameter.Key,
+                Name = routeParameter.Value.JsonPath,
                 ModelMetadata = new GrpcModelMetadata(identity),
                 Source = BindingSource.Path,
                 DefaultValue = string.Empty
@@ -135,9 +137,54 @@ internal sealed class GrpcJsonTranscodingDescriptionProvider : IApiDescriptionPr
             });
         }
 
+        var queryParameters = ServiceDescriptorHelpers.ResolveQueryParameterDescriptors(routeParameters, methodDescriptor, bodyDescriptor?.Descriptor, bodyDescriptor?.FieldDescriptors);
+        foreach (var queryDescription in queryParameters)
+        {
+            var fieldType = MessageDescriptorHelpers.ResolveFieldType(queryDescription.Value);
+            if (queryDescription.Value.IsRepeated)
+            {
+                fieldType = typeof(List<>).MakeGenericType(fieldType);
+            }
+
+            apiDescription.ParameterDescriptions.Add(new ApiParameterDescription
+            {
+                Name = queryDescription.Key,
+                ModelMetadata = new GrpcModelMetadata(ModelMetadataIdentity.ForType(fieldType)),
+                Source = BindingSource.Query,
+                DefaultValue = string.Empty
+            });
+        }
+
         return apiDescription;
     }
 
+    private static string ResolvePath(HttpRoutePattern httpRoutePattern, Dictionary<string, RouteParameter> routeParameters)
+    {
+        var sb = new StringBuilder();
+        for (var i = 0; i < httpRoutePattern.Segments.Count; i++)
+        {
+            if (sb.Length > 0)
+            {
+                sb.Append('/');
+            }
+            var routeParameter = routeParameters.SingleOrDefault(kvp => kvp.Value.RouteVariable.StartSegment == i).Value;
+            if (routeParameter != null)
+            {
+                sb.Append('{');
+                sb.Append(routeParameter.JsonPath);
+                sb.Append('}');
+
+                // Skip segments if variable is multiple segment.
+                i = routeParameter.RouteVariable.EndSegment - 1;
+            }
+            else
+            {
+                sb.Append(httpRoutePattern.Segments[i]);
+            }
+        }
+        return sb.ToString();
+    }
+
     public void OnProvidersExecuted(ApiDescriptionProviderContext context)
     {
         // no-op

+ 0 - 1
src/Grpc/JsonTranscoding/src/Shared/HttpRoutePattern.cs

@@ -27,7 +27,6 @@ internal sealed class HttpRoutePattern
 
 internal sealed class HttpRouteVariable
 {
-    public int Index { get; set; }
     public int StartSegment { get; set; }
     public int EndSegment { get; set; }
     public List<string> FieldPath { get; } = new List<string>();

+ 106 - 10
src/Grpc/JsonTranscoding/src/Shared/ServiceDescriptorHelpers.cs

@@ -25,9 +25,6 @@ using Google.Api;
 using Google.Protobuf;
 using Google.Protobuf.Reflection;
 using Google.Protobuf.WellKnownTypes;
-using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal;
-using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json;
-using Microsoft.AspNetCore.Routing.Patterns;
 using Microsoft.Extensions.Primitives;
 using Type = System.Type;
 
@@ -288,18 +285,22 @@ internal static class ServiceDescriptorHelpers
         }
     }
 
-    public static Dictionary<string, List<FieldDescriptor>> ResolveRouteParameterDescriptors(List<List<string>> parameters, MessageDescriptor messageDescriptor)
+    public static Dictionary<string, RouteParameter> ResolveRouteParameterDescriptors(
+        List<HttpRouteVariable> variables,
+        MessageDescriptor messageDescriptor)
     {
-        var routeParameterDescriptors = new Dictionary<string, List<FieldDescriptor>>(StringComparer.Ordinal);
-        foreach (var routeParameter in parameters)
+        var routeParameterDescriptors = new Dictionary<string, RouteParameter>(StringComparer.Ordinal);
+        foreach (var variable in variables)
         {
-            var completeFieldPath = string.Join(".", routeParameter);
-            if (!TryResolveDescriptors(messageDescriptor, routeParameter, out var fieldDescriptors))
+            var path = variable.FieldPath;
+            if (!TryResolveDescriptors(messageDescriptor, path, out var fieldDescriptors))
             {
-                throw new InvalidOperationException($"Couldn't find matching field for route parameter '{completeFieldPath}' on {messageDescriptor.Name}.");
+                throw new InvalidOperationException($"Couldn't find matching field for route parameter '{string.Join(".", path)}' on {messageDescriptor.Name}.");
             }
 
-            routeParameterDescriptors.Add(completeFieldPath, fieldDescriptors);
+            var completeFieldPath = string.Join(".", fieldDescriptors.Select(d => d.Name));
+            var completeJsonPath = string.Join(".", fieldDescriptors.Select(d => d.JsonName));
+            routeParameterDescriptors.Add(completeFieldPath, new RouteParameter(fieldDescriptors, variable, completeJsonPath));
         }
 
         return routeParameterDescriptors;
@@ -347,6 +348,96 @@ internal static class ServiceDescriptorHelpers
         return null;
     }
 
+    public static Dictionary<string, FieldDescriptor> ResolveQueryParameterDescriptors(
+        Dictionary<string, RouteParameter> routeParameters,
+        MethodDescriptor methodDescriptor,
+        MessageDescriptor? bodyDescriptor,
+        List<FieldDescriptor>? bodyFieldDescriptors)
+    {
+        var existingParameters = new List<FieldDescriptor>();
+
+        foreach (var routeParameter in routeParameters)
+        {
+            // Each route field descriptors collection contains all the descriptors in the path.
+            // We only care about the final place the route value is set and so add only the last
+            // descriptor to the existing parameters collection.
+            existingParameters.Add(routeParameter.Value.DescriptorsPath.Last());
+        }
+
+        if (bodyDescriptor != null)
+        {
+            if (bodyFieldDescriptors != null)
+            {
+                // Body with field name.
+                // The body field descriptors collection contains all the descriptors in the path.
+                // We only care about the final place the body is set and so add only the last
+                // descriptor to the existing parameters collection.
+                existingParameters.Add(bodyFieldDescriptors.Last());
+            }
+            else
+            {
+                // Body with wildcard. All parameters are in the body so no query parameters.
+                return new Dictionary<string, FieldDescriptor>();
+            }
+        }
+
+        var queryParameters = new Dictionary<string, FieldDescriptor>();
+        RecursiveVisitMessages(queryParameters, existingParameters, methodDescriptor.InputType, new List<FieldDescriptor>());
+        return queryParameters;
+
+        static void RecursiveVisitMessages(Dictionary<string, FieldDescriptor> queryParameters, List<FieldDescriptor> existingParameters, MessageDescriptor messageDescriptor, List<FieldDescriptor> path)
+        {
+            var messageFields = messageDescriptor.Fields.InFieldNumberOrder();
+
+            foreach (var fieldDescriptor in messageFields)
+            {
+                // If a field is set via route parameter or body then don't add query parameter.
+                if (existingParameters.Contains(fieldDescriptor))
+                {
+                    continue;
+                }
+
+                // Add current field descriptor. It should be included in the path.
+                path.Add(fieldDescriptor);
+
+                switch (fieldDescriptor.FieldType)
+                {
+                    case FieldType.Double:
+                    case FieldType.Float:
+                    case FieldType.Int64:
+                    case FieldType.UInt64:
+                    case FieldType.Int32:
+                    case FieldType.Fixed64:
+                    case FieldType.Fixed32:
+                    case FieldType.Bool:
+                    case FieldType.String:
+                    case FieldType.Bytes:
+                    case FieldType.UInt32:
+                    case FieldType.SFixed32:
+                    case FieldType.SFixed64:
+                    case FieldType.SInt32:
+                    case FieldType.SInt64:
+                    case FieldType.Enum:
+                        var joinedPath = string.Join(".", path.Select(d => d.JsonName));
+                        queryParameters[joinedPath] = fieldDescriptor;
+                        break;
+                    case FieldType.Group:
+                    case FieldType.Message:
+                    default:
+                        // Complex repeated fields aren't valid query parameters.
+                        if (!fieldDescriptor.IsRepeated)
+                        {
+                            RecursiveVisitMessages(queryParameters, existingParameters, fieldDescriptor.MessageType, path);
+                        }
+                        break;
+                }
+
+                // Remove current field descriptor.
+                path.RemoveAt(path.Count - 1);
+            }
+        }
+    }
+
     public sealed record BodyDescriptorInfo(
         MessageDescriptor Descriptor,
         List<FieldDescriptor>? FieldDescriptors,
@@ -410,3 +501,8 @@ internal static class ServiceDescriptorHelpers
         return result;
     }
 }
+
+internal record RouteParameter(
+    List<FieldDescriptor> DescriptorsPath,
+    HttpRouteVariable RouteVariable,
+    string JsonPath);

+ 2 - 2
src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/TestHelpers.cs

@@ -59,7 +59,7 @@ internal static class TestHelpers
 
     public static CallHandlerDescriptorInfo CreateDescriptorInfo(
         FieldDescriptor? responseBodyDescriptor = null,
-        Dictionary<string, List<FieldDescriptor>>? routeParameterDescriptors = null,
+        Dictionary<string, RouteParameter>? routeParameterDescriptors = null,
         MessageDescriptor? bodyDescriptor = null,
         bool? bodyDescriptorRepeated = null,
         List<FieldDescriptor>? bodyFieldDescriptors = null)
@@ -69,7 +69,7 @@ internal static class TestHelpers
             bodyDescriptor,
             bodyDescriptorRepeated ?? false,
             bodyFieldDescriptors,
-            routeParameterDescriptors ?? new Dictionary<string, List<FieldDescriptor>>(),
+            routeParameterDescriptors ?? new Dictionary<string, RouteParameter>(),
             JsonTranscodingRouteAdapter.Parse(HttpRoutePattern.Parse("/")));
     }
 }

+ 2 - 2
src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/TestHelpers.cs

@@ -60,7 +60,7 @@ internal static class TestHelpers
 
     public static CallHandlerDescriptorInfo CreateDescriptorInfo(
         FieldDescriptor? responseBodyDescriptor = null,
-        Dictionary<string, List<FieldDescriptor>>? routeParameterDescriptors = null,
+        Dictionary<string, RouteParameter>? routeParameterDescriptors = null,
         MessageDescriptor? bodyDescriptor = null,
         bool? bodyDescriptorRepeated = null,
         List<FieldDescriptor>? bodyFieldDescriptors = null)
@@ -70,7 +70,7 @@ internal static class TestHelpers
             bodyDescriptor,
             bodyDescriptorRepeated ?? false,
             bodyFieldDescriptors,
-            routeParameterDescriptors ?? new Dictionary<string, List<FieldDescriptor>>(),
+            routeParameterDescriptors ?? new Dictionary<string, RouteParameter>(),
             JsonTranscodingRouteAdapter.Parse(HttpRoutePattern.Parse("/")));
     }
 }

+ 1 - 1
src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/JsonTranscodingServerCallContextTests.cs

@@ -100,7 +100,7 @@ public class JsonTranscodingServerCallContextTests
                 null,
                 false,
                 null,
-                new Dictionary<string, List<FieldDescriptor>>(),
+                new Dictionary<string, RouteParameter>(),
                 JsonTranscodingRouteAdapter.Parse(HttpRoutePattern.Parse("/")!)),
             NullLogger.Instance);
     }

+ 17 - 10
src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ServerStreamingServerCallHandlerTests.cs

@@ -12,6 +12,7 @@ using Google.Protobuf.Reflection;
 using Grpc.AspNetCore.Server;
 using Grpc.AspNetCore.Server.Model;
 using Grpc.Core;
+using Grpc.Shared;
 using Grpc.Shared.Server;
 using Grpc.Tests.Shared;
 using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers;
@@ -44,9 +45,10 @@ public class ServerStreamingServerCallHandlerTests : LoggedTest
 
         var pipe = new Pipe();
 
-        var routeParameterDescriptors = new Dictionary<string, List<FieldDescriptor>>
+        var descriptorPath = new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) });
+        var routeParameterDescriptors = new Dictionary<string, RouteParameter>
         {
-            ["name"] = new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) })
+            ["name"] = CreateRouteParameter(descriptorPath)
         };
         var descriptorInfo = TestHelpers.CreateDescriptorInfo(routeParameterDescriptors: routeParameterDescriptors);
         var callHandler = CreateCallHandler(invoker, descriptorInfo: descriptorInfo);
@@ -74,6 +76,11 @@ public class ServerStreamingServerCallHandlerTests : LoggedTest
         await callTask.DefaultTimeout();
     }
 
+    private static RouteParameter CreateRouteParameter(List<FieldDescriptor> descriptorPath)
+    {
+        return new RouteParameter(descriptorPath, new HttpRouteVariable(), string.Empty);
+    }
+
     [Fact]
     public async Task HandleCallAsync_MessageThenError_MessageThenErrorReturned()
     {
@@ -86,9 +93,9 @@ public class ServerStreamingServerCallHandlerTests : LoggedTest
 
         var pipe = new Pipe();
 
-        var routeParameterDescriptors = new Dictionary<string, List<FieldDescriptor>>
+        var routeParameterDescriptors = new Dictionary<string, RouteParameter>
         {
-            ["name"] = new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) })
+            ["name"] = CreateRouteParameter(new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) }))
         };
         var descriptorInfo = TestHelpers.CreateDescriptorInfo(routeParameterDescriptors: routeParameterDescriptors);
         var callHandler = CreateCallHandler(invoker, descriptorInfo: descriptorInfo);
@@ -128,9 +135,9 @@ public class ServerStreamingServerCallHandlerTests : LoggedTest
 
         var pipe = new Pipe();
 
-        var routeParameterDescriptors = new Dictionary<string, List<FieldDescriptor>>
+        var routeParameterDescriptors = new Dictionary<string, RouteParameter>
         {
-            ["name"] = new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) })
+            ["name"] = CreateRouteParameter(new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) }))
         };
         var descriptorInfo = TestHelpers.CreateDescriptorInfo(routeParameterDescriptors: routeParameterDescriptors);
         var callHandler = CreateCallHandler(invoker, descriptorInfo: descriptorInfo);
@@ -168,9 +175,9 @@ public class ServerStreamingServerCallHandlerTests : LoggedTest
 
         var pipe = new Pipe();
 
-        var routeParameterDescriptors = new Dictionary<string, List<FieldDescriptor>>
+        var routeParameterDescriptors = new Dictionary<string, RouteParameter>
         {
-            ["name"] = new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) })
+            ["name"] = CreateRouteParameter(new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) }))
         };
         var descriptorInfo = TestHelpers.CreateDescriptorInfo(routeParameterDescriptors: routeParameterDescriptors);
         var serviceOptions = new GrpcServiceOptions { EnableDetailedErrors = true };
@@ -217,9 +224,9 @@ public class ServerStreamingServerCallHandlerTests : LoggedTest
 
         var pipe = new Pipe();
 
-        var routeParameterDescriptors = new Dictionary<string, List<FieldDescriptor>>
+        var routeParameterDescriptors = new Dictionary<string, RouteParameter>
         {
-            ["name"] = new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) })
+            ["name"] = CreateRouteParameter(new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) }))
         };
         var descriptorInfo = TestHelpers.CreateDescriptorInfo(routeParameterDescriptors: routeParameterDescriptors);
         var callHandler = CreateCallHandler(

+ 13 - 8
src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/UnaryServerCallHandlerTests.cs

@@ -33,6 +33,11 @@ public class UnaryServerCallHandlerTests : LoggedTest
 {
     public UnaryServerCallHandlerTests(ITestOutputHelper output) : base(output) { }
 
+    private static RouteParameter CreateRouteParameter(List<FieldDescriptor> descriptorPath)
+    {
+        return new RouteParameter(descriptorPath, new HttpRouteVariable(), string.Empty);
+    }
+
     [Fact]
     public async Task HandleCallAsync_MatchingRouteValue_SetOnRequestMessage()
     {
@@ -44,14 +49,14 @@ public class UnaryServerCallHandlerTests : LoggedTest
             return Task.FromResult(new HelloReply { Message = $"Hello {r.Name}" });
         };
 
-        var routeParameterDescriptors = new Dictionary<string, List<FieldDescriptor>>
+        var routeParameterDescriptors = new Dictionary<string, RouteParameter>
         {
-            ["name"] = new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) }),
-            ["sub.subfield"] = new List<FieldDescriptor>(new[]
+            ["name"] = CreateRouteParameter(new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) })),
+            ["sub.subfield"] = CreateRouteParameter(new List<FieldDescriptor>(new[]
             {
                 HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.SubFieldNumber),
                 HelloRequest.Types.SubMessage.Descriptor.FindFieldByNumber(HelloRequest.Types.SubMessage.SubfieldFieldNumber)
-            })
+            }))
         };
         var descriptorInfo = TestHelpers.CreateDescriptorInfo(routeParameterDescriptors: routeParameterDescriptors);
         var unaryServerCallHandler = CreateCallHandler(invoker, descriptorInfo: descriptorInfo);
@@ -88,9 +93,9 @@ public class UnaryServerCallHandlerTests : LoggedTest
             return Task.FromResult(new HelloReply { Message = r.Name });
         };
 
-        var routeParameterDescriptors = new Dictionary<string, List<FieldDescriptor>>
+        var routeParameterDescriptors = new Dictionary<string, RouteParameter>
         {
-            ["name"] = new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) })
+            ["name"] = CreateRouteParameter(new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) }))
         };
         var descriptorInfo = TestHelpers.CreateDescriptorInfo(
             responseBodyDescriptor: HelloReply.Descriptor.FindFieldByNumber(HelloReply.MessageFieldNumber),
@@ -122,9 +127,9 @@ public class UnaryServerCallHandlerTests : LoggedTest
             return Task.FromResult(new HelloReply { NullableMessage = null });
         };
 
-        var routeParameterDescriptors = new Dictionary<string, List<FieldDescriptor>>
+        var routeParameterDescriptors = new Dictionary<string, RouteParameter>
         {
-            ["name"] = new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) })
+            ["name"] = CreateRouteParameter(new List<FieldDescriptor>(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) }))
         };
         var descriptorInfo = TestHelpers.CreateDescriptorInfo(
             responseBodyDescriptor: HelloReply.Descriptor.FindFieldByNumber(HelloReply.NullableMessageFieldNumber),

+ 1 - 11
src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/GrpcSwaggerServiceExtensionsTests.cs

@@ -4,10 +4,10 @@
 using Count;
 using Greet;
 using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Grpc.Swagger.Tests.Infrastructure;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.FileProviders;
 using Microsoft.OpenApi.Models;
 using Swashbuckle.AspNetCore.Swagger;
 
@@ -88,16 +88,6 @@ public class GrpcSwaggerServiceExtensionsTests
         Assert.True(swagger.Paths["/v1/add/{value1}/{value2}"].Operations.ContainsKey(OperationType.Get));
     }
 
-    private class TestWebHostEnvironment : IWebHostEnvironment
-    {
-        public IFileProvider WebRootFileProvider { get; set; }
-        public string WebRootPath { get; set; }
-        public string ApplicationName { get; set; }
-        public IFileProvider ContentRootFileProvider { get; set; }
-        public string ContentRootPath { get; set; }
-        public string EnvironmentName { get; set; }
-    }
-
     private class GreeterService : Greeter.GreeterBase
     {
     }

+ 49 - 0
src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Infrastructure/OpenApiTestHelpers.cs

@@ -0,0 +1,49 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.OpenApi.Models;
+using Microsoft.OpenApi.Writers;
+using Swashbuckle.AspNetCore.Swagger;
+using Xunit.Abstractions;
+
+namespace Microsoft.AspNetCore.Grpc.Swagger.Tests.Infrastructure;
+
+internal static class OpenApiTestHelpers
+{
+    public static OpenApiDocument GetOpenApiDocument<TService>(ITestOutputHelper testOutputHelper) where TService : class
+    {
+        var services = new ServiceCollection();
+        services.AddGrpcSwagger();
+        services.AddSwaggerGen(c =>
+        {
+            c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
+
+            var filePath = Path.Combine(System.AppContext.BaseDirectory, "Microsoft.AspNetCore.Grpc.Swagger.Tests.xml");
+            c.IncludeXmlComments(filePath);
+            c.IncludeGrpcXmlComments(filePath, includeControllerXmlComments: true);
+        });
+        services.AddRouting();
+        services.AddLogging();
+        services.AddSingleton<IWebHostEnvironment, TestWebHostEnvironment>();
+        var serviceProvider = services.BuildServiceProvider();
+        var app = new ApplicationBuilder(serviceProvider);
+
+        app.UseRouting();
+        app.UseEndpoints(c =>
+        {
+            c.MapGrpcService<TService>();
+        });
+
+        var swaggerGenerator = serviceProvider.GetRequiredService<ISwaggerProvider>();
+        var swagger = swaggerGenerator.GetSwagger("v1");
+
+        using var outputString = new StringWriter();
+        swagger.SerializeAsV3(new OpenApiJsonWriter(outputString));
+        testOutputHelper.WriteLine(outputString.ToString());
+
+        return swagger;
+    }
+}

+ 17 - 0
src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Infrastructure/TestWebHostEnvironment.cs

@@ -0,0 +1,17 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.FileProviders;
+
+namespace Microsoft.AspNetCore.Grpc.Swagger.Tests.Infrastructure;
+
+internal class TestWebHostEnvironment : IWebHostEnvironment
+{
+    public IFileProvider WebRootFileProvider { get; set; }
+    public string WebRootPath { get; set; }
+    public string ApplicationName { get; set; }
+    public IFileProvider ContentRootFileProvider { get; set; }
+    public string ContentRootPath { get; set; }
+    public string EnvironmentName { get; set; }
+}

+ 2 - 1
src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Microsoft.AspNetCore.Grpc.Swagger.Tests.csproj

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
   <PropertyGroup>
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
@@ -7,6 +7,7 @@
 
   <ItemGroup>
     <Protobuf Include="Proto\counter.proto" GrpcServices="Both" />
+    <Protobuf Include="Proto\parameters.proto" GrpcServices="Both" />
     <Protobuf Include="Proto\xmldoc.proto" GrpcServices="Both" />
     <Protobuf Include="Proto\greeter.proto" GrpcServices="Both" />
     <Protobuf Include="Proto\messages.proto" GrpcServices="Both" />

+ 141 - 0
src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Parameters/ParametersTests.cs

@@ -0,0 +1,141 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Grpc.Swagger.Tests.Infrastructure;
+using Microsoft.AspNetCore.Grpc.Swagger.Tests.Services;
+using Microsoft.OpenApi.Models;
+using Xunit.Abstractions;
+
+namespace Microsoft.AspNetCore.Grpc.Swagger.Tests.Parameters;
+
+public class ParametersTests
+{
+    private readonly ITestOutputHelper _testOutputHelper;
+
+    public ParametersTests(ITestOutputHelper testOutputHelper)
+    {
+        _testOutputHelper = testOutputHelper;
+    }
+
+    [Fact]
+    public void NoRouteOrBody_AllQueryFields()
+    {
+        // Arrange & Act
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<ParametersService>(_testOutputHelper);
+
+        // Assert
+        var path = swagger.Paths["/v1/parameters1"];
+        Assert.True(path.Operations.TryGetValue(OperationType.Get, out var operation));
+        Assert.Equal(2, operation.Parameters.Count);
+        Assert.Equal(ParameterLocation.Query, operation.Parameters[0].In);
+        Assert.Equal("parameterInt", operation.Parameters[0].Name);
+        Assert.Equal(ParameterLocation.Query, operation.Parameters[1].In);
+        Assert.Equal("parameterString", operation.Parameters[1].Name);
+    }
+
+    [Fact]
+    public void RouteFields_FilterRouteQueryFields()
+    {
+        // Arrange & Act
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<ParametersService>(_testOutputHelper);
+
+        // Assert
+        var path = swagger.Paths["/v1/parameters2/{parameterInt}"];
+        Assert.True(path.Operations.TryGetValue(OperationType.Get, out var operation));
+        Assert.Equal(2, operation.Parameters.Count);
+        Assert.Equal(ParameterLocation.Path, operation.Parameters[0].In);
+        Assert.Equal("parameterInt", operation.Parameters[0].Name);
+        Assert.Equal(ParameterLocation.Query, operation.Parameters[1].In);
+        Assert.Equal("parameterString", operation.Parameters[1].Name);
+    }
+
+    [Fact]
+    public void RouteAndBodyFields_FilterRouteAndBodyQueryFields()
+    {
+        // Arrange & Act
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<ParametersService>(_testOutputHelper);
+
+        // Assert
+        var path = swagger.Paths["/v1/parameters3/{parameterOne}"];
+        Assert.True(path.Operations.TryGetValue(OperationType.Post, out var operation));
+        Assert.Equal(3, operation.Parameters.Count);
+        Assert.Equal(ParameterLocation.Path, operation.Parameters[0].In);
+        Assert.Equal("parameterOne", operation.Parameters[0].Name);
+        Assert.Equal(ParameterLocation.Query, operation.Parameters[1].In);
+        Assert.Equal("parameterTwo", operation.Parameters[1].Name);
+        Assert.Equal(ParameterLocation.Query, operation.Parameters[2].In);
+        Assert.Equal("parameterThree", operation.Parameters[2].Name);
+        // body with one parameter
+        Assert.NotNull(operation.RequestBody);
+        Assert.Equal(1, swagger.Components.Schemas["RequestBody"].Properties.Count);
+    }
+
+    [Fact]
+    public void CatchAllBody_NoQueryFields()
+    {
+        // Arrange & Act
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<ParametersService>(_testOutputHelper);
+
+        // Assert
+        var path = swagger.Paths["/v1/parameters4/{parameterTwo}"];
+        Assert.True(path.Operations.TryGetValue(OperationType.Post, out var operation));
+        Assert.Equal(1, operation.Parameters.Count);
+        Assert.Equal(ParameterLocation.Path, operation.Parameters[0].In);
+        Assert.Equal("parameterTwo", operation.Parameters[0].Name);
+        // body with four parameters
+        Assert.NotNull(operation.RequestBody);
+        Assert.Equal(4, swagger.Components.Schemas["RequestTwo"].Properties.Count);
+    }
+
+    [Fact]
+    public void NoBodyComplexType_NestedQueryField()
+    {
+        // Arrange & Act
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<ParametersService>(_testOutputHelper);
+
+        // Assert
+        var path = swagger.Paths["/v1/parameters5/{parameterOne}"];
+        Assert.True(path.Operations.TryGetValue(OperationType.Get, out var operation));
+        Assert.Equal(4, operation.Parameters.Count);
+        Assert.Equal(ParameterLocation.Query, operation.Parameters[3].In);
+        Assert.Equal("parameterFour.requestBody", operation.Parameters[3].Name);
+    }
+
+    [Fact]
+    public void RepeatedStringField_ArrayQueryField()
+    {
+        // Arrange & Act
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<ParametersService>(_testOutputHelper);
+
+        // Assert
+        var path = swagger.Paths["/v1/parameters6"];
+        Assert.True(path.Operations.TryGetValue(OperationType.Get, out var operation));
+        Assert.Equal(1, operation.Parameters.Count);
+        Assert.Equal(ParameterLocation.Query, operation.Parameters[0].In);
+        Assert.Equal("parameterOne", operation.Parameters[0].Name);
+        Assert.Equal("array", operation.Parameters[0].Schema.Type);
+        Assert.Equal("integer", operation.Parameters[0].Schema.Items.Type);
+    }
+
+    [Fact]
+    public void MultipleRouteParameter_NestedFields_MissingFieldsAreQuery()
+    {
+        // Arrange & Act
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<ParametersService>(_testOutputHelper);
+
+        // Assert
+        var path = swagger.Paths["/v1/parameters7/{parameterOne.nestedParameterOne}/{parameterOne.nestedParameterTwo}"];
+        Assert.True(path.Operations.TryGetValue(OperationType.Get, out var operation));
+        Assert.Equal(5, operation.Parameters.Count);
+        Assert.Equal(ParameterLocation.Path, operation.Parameters[0].In);
+        Assert.Equal("parameterOne.nestedParameterOne", operation.Parameters[0].Name);
+        Assert.Equal(ParameterLocation.Path, operation.Parameters[1].In);
+        Assert.Equal("parameterOne.nestedParameterTwo", operation.Parameters[1].Name);
+        Assert.Equal(ParameterLocation.Query, operation.Parameters[2].In);
+        Assert.Equal("parameterOne.nestedParameterThree", operation.Parameters[2].Name);
+        Assert.Equal(ParameterLocation.Query, operation.Parameters[3].In);
+        Assert.Equal("parameterOne.nestedParameterFour", operation.Parameters[3].Name);
+        Assert.Equal(ParameterLocation.Query, operation.Parameters[4].In);
+        Assert.Equal("parameterTwo", operation.Parameters[4].Name);
+    }
+}

+ 112 - 0
src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/parameters.proto

@@ -0,0 +1,112 @@
+// 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.
+
+syntax = "proto3";
+
+package params;
+
+import "google/api/annotations.proto";
+
+// Add go_package to keep protoc happy when testing generating OpenAPI from commandline.
+option go_package = "github.com/dotnet/aspnetcore/swagger";
+
+// HttpRule: https://cloud.google.com/endpoints/docs/grpc-service-config/reference/rpc/google.api#google.api.HttpRule
+
+service Parameters {
+  // parameter_int & parameter_string should be query parameters
+  rpc DemoParametersOne (RequestOne) returns (ParamResponse) {
+    option (google.api.http) = {
+      get: "/v1/parameters1"
+    };
+  }
+
+  // parameter_string should be query parameters
+  rpc DemoParametersTwo (RequestOne) returns (ParamResponse) {
+    option (google.api.http) = {
+      get: "/v1/parameters2/{parameter_int}"
+    };
+  }
+
+  // parameter_two & parameter_three should be query parameters
+  rpc DemoParametersThree (RequestTwo) returns (ParamResponse) {
+    option (google.api.http) = {
+      post: "/v1/parameters3/{parameter_one}"
+      body: "parameter_four"
+    };
+  }
+
+  // no query parameters
+  rpc DemoParametersFour (RequestTwo) returns (ParamResponse) {
+    option (google.api.http) = {
+      post: "/v1/parameters4/{parameter_two}"
+      body: "*"
+    };
+  }
+
+  // parameter_two & parameter_three & parameter_four should be query parameters
+  rpc DemoParametersFive (RequestTwo) returns (ParamResponse) {
+    option (google.api.http) = {
+      get: "/v1/parameters5/{parameter_one}"
+    };
+  }
+
+  // parameter_two & parameter_three & parameter_four should be query parameters
+  rpc DemoParametersSix (RequestThree) returns (ParamResponse) {
+    option (google.api.http) = {
+      get: "/v1/parameters6"
+    };
+  }
+
+  // parameter_two & parameter_one.nested_parameter_three & parameter_one.nested_parameter_four should be query parameters
+  rpc DemoParametersSeven (RequestFour) returns (ParamResponse) {
+    option (google.api.http) = {
+      get: "/v1/parameters7/{parameter_one.nested_parameter_one}/{parameter_one.nested_parameter_two}"
+    };
+  }
+
+  rpc DemoParametersEight (RequestFour) returns (ParamResponse) {
+    option (google.api.http) = {
+      get: "/v1/parameters8/{parameter_one.nested_parameter_one=messages1/*}/{parameter_one.nested_parameter_two=shelves/*/books/*}"
+    };
+  }
+}
+
+message RequestOne {
+  int64 parameter_int = 1;
+  string parameter_string = 2;
+}
+
+message RequestTwo {
+  int64 parameter_one = 1;
+  string parameter_two = 2;
+  int64 parameter_three = 3;
+  RequestBody parameter_four = 45;
+}
+
+message RequestThree {
+  repeated int64 parameter_one = 1;
+  // repeated complex field not a valid query parameter
+  repeated RequestBody parameter_two = 2;
+  // map field not a valid query parameter
+  map<string, string> parameter_three = 3;
+}
+
+message RequestFour {
+  Nested parameter_one = 1;
+  string parameter_two = 2;
+
+  message Nested {
+    int64 nested_parameter_one = 1;
+    string nested_parameter_two = 2;
+    int64 nested_parameter_three = 3;
+    repeated int64 nested_parameter_four = 4;
+  }
+}
+
+message RequestBody {
+  string request_body = 1;
+}
+
+message ParamResponse {
+  string message = 1;
+}

+ 30 - 0
src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/ParametersService.cs

@@ -0,0 +1,30 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Grpc.Core;
+using Params;
+
+namespace Microsoft.AspNetCore.Grpc.Swagger.Tests.Services;
+
+public class ParametersService : Params.Parameters.ParametersBase
+{
+    public override Task<ParamResponse> DemoParametersOne(RequestOne requestId, ServerCallContext ctx)
+    {
+        return Task.FromResult(new ParamResponse { Message = "DemoParametersOne Response" });
+    }
+
+    public override Task<ParamResponse> DemoParametersTwo(RequestOne requestId, ServerCallContext ctx)
+    {
+        return Task.FromResult(new ParamResponse { Message = "DemoParametersTwo Response" });
+    }
+
+    public override Task<ParamResponse> DemoParametersThree(RequestTwo request, ServerCallContext ctx)
+    {
+        return Task.FromResult(new ParamResponse { Message = "DemoParametersThree Response " });
+    }
+
+    public override Task<ParamResponse> DemoParametersFour(RequestTwo request, ServerCallContext ctx)
+    {
+        return Task.FromResult(new ParamResponse { Message = "DemoParametersFour Response" });
+    }
+}

+ 10 - 59
src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/XmlComments/XmlDocumentationIntegrationTests.cs

@@ -2,14 +2,9 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using Greet;
-using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Grpc.Swagger.Tests.Infrastructure;
 using Microsoft.AspNetCore.Grpc.Swagger.Tests.Services;
-using Microsoft.AspNetCore.Hosting;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.FileProviders;
 using Microsoft.OpenApi.Models;
-using Microsoft.OpenApi.Writers;
-using Swashbuckle.AspNetCore.Swagger;
 using Xunit.Abstractions;
 
 namespace Microsoft.AspNetCore.Grpc.Swagger.Tests.XmlComments;
@@ -27,7 +22,7 @@ public class XmlDocumentationIntegrationTests
     public void ServiceDescription_ModelHasXmlDocs_UseXmlDocs()
     {
         // Arrange & Act
-        var swagger = GetOpenApiDocument<XmlDocServiceWithComments>();
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<XmlDocServiceWithComments>(_testOutputHelper);
 
         // Assert
         Assert.Equal("XmlDoc", swagger.Tags[0].Name);
@@ -38,7 +33,7 @@ public class XmlDocumentationIntegrationTests
     public void ServiceDescription_ModelDoesntHaveXmlDocs_UseProtoDocs()
     {
         // Arrange & Act
-        var swagger = GetOpenApiDocument<XmlDocService>();
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<XmlDocService>(_testOutputHelper);
 
         // Assert
         Assert.Equal("XmlDoc", swagger.Tags[0].Name);
@@ -49,7 +44,7 @@ public class XmlDocumentationIntegrationTests
     public void RouteParameter_UseProtoDocs()
     {
         // Arrange & Act
-        var swagger = GetOpenApiDocument<XmlDocServiceWithComments>();
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<XmlDocServiceWithComments>(_testOutputHelper);
 
         // Assert
         var path = swagger.Paths["/v1/greeter/{name}"];
@@ -60,7 +55,7 @@ public class XmlDocumentationIntegrationTests
     public void MethodDescription_ModelHasXmlDocs_UseXmlDocs()
     {
         // Arrange & Act
-        var swagger = GetOpenApiDocument<XmlDocServiceWithComments>();
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<XmlDocServiceWithComments>(_testOutputHelper);
 
         // Assert
         var path = swagger.Paths["/v1/greeter/{name}"];
@@ -72,7 +67,7 @@ public class XmlDocumentationIntegrationTests
     public void MethodDescription_ModelDoesntHaveXmlDocs_UseProtoDocs()
     {
         // Arrange & Act
-        var swagger = GetOpenApiDocument<XmlDocService>();
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<XmlDocService>(_testOutputHelper);
 
         // Assert
         var path = swagger.Paths["/v1/greeter/{name}"];
@@ -84,7 +79,7 @@ public class XmlDocumentationIntegrationTests
     public void RequestDescription_Root_ModelHasXmlDocs_UseXmlDocs()
     {
         // Arrange & Act
-        var swagger = GetOpenApiDocument<XmlDocServiceWithComments>();
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<XmlDocServiceWithComments>(_testOutputHelper);
 
         // Assert
         var path = swagger.Paths["/v1/greeter"];
@@ -95,7 +90,7 @@ public class XmlDocumentationIntegrationTests
     public void RequestDescription_Root_ModelDoesntHaveXmlDocs_Empty()
     {
         // Arrange & Act
-        var swagger = GetOpenApiDocument<XmlDocService>();
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<XmlDocService>(_testOutputHelper);
 
         // Assert
         var path = swagger.Paths["/v1/greeter"];
@@ -106,7 +101,7 @@ public class XmlDocumentationIntegrationTests
     public void RequestDescription_Nested_ProtoFieldDocs()
     {
         // Arrange & Act
-        var swagger = GetOpenApiDocument<XmlDocService>();
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<XmlDocService>(_testOutputHelper);
 
         // Assert
         var path = swagger.Paths["/v1/greeter/{name}"];
@@ -117,7 +112,7 @@ public class XmlDocumentationIntegrationTests
     public void Message_UseProtoDocs()
     {
         // Arrange & Act
-        var swagger = GetOpenApiDocument<XmlDocServiceWithComments>();
+        var swagger = OpenApiTestHelpers.GetOpenApiDocument<XmlDocServiceWithComments>(_testOutputHelper);
 
         // Assert
         var helloReplyMessage = swagger.Components.Schemas["StringReply"];
@@ -125,50 +120,6 @@ public class XmlDocumentationIntegrationTests
         Assert.Equal("Message field!", helloReplyMessage.Properties["message"].Description);
     }
 
-    private OpenApiDocument GetOpenApiDocument<TService>() where TService : class
-    {
-        var services = new ServiceCollection();
-        services.AddGrpcSwagger();
-        services.AddSwaggerGen(c =>
-        {
-            c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
-
-            var filePath = Path.Combine(System.AppContext.BaseDirectory, "Microsoft.AspNetCore.Grpc.Swagger.Tests.xml");
-            c.IncludeXmlComments(filePath);
-            c.IncludeGrpcXmlComments(filePath, includeControllerXmlComments: true);
-        });
-        services.AddRouting();
-        services.AddLogging();
-        services.AddSingleton<IWebHostEnvironment, TestWebHostEnvironment>();
-        var serviceProvider = services.BuildServiceProvider();
-        var app = new ApplicationBuilder(serviceProvider);
-
-        app.UseRouting();
-        app.UseEndpoints(c =>
-        {
-            c.MapGrpcService<TService>();
-        });
-
-        var swaggerGenerator = serviceProvider.GetRequiredService<ISwaggerProvider>();
-        var swagger = swaggerGenerator.GetSwagger("v1");
-
-        using var outputString = new StringWriter();
-        swagger.SerializeAsV3(new OpenApiJsonWriter(outputString));
-        _testOutputHelper.WriteLine(outputString.ToString());
-
-        return swagger;
-    }
-
-    private class TestWebHostEnvironment : IWebHostEnvironment
-    {
-        public IFileProvider WebRootFileProvider { get; set; }
-        public string WebRootPath { get; set; }
-        public string ApplicationName { get; set; }
-        public IFileProvider ContentRootFileProvider { get; set; }
-        public string ContentRootPath { get; set; }
-        public string EnvironmentName { get; set; }
-    }
-
     private class GreeterService : Greeter.GreeterBase
     {
     }