diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcJsonTranscodingDescriptionProvider.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcJsonTranscodingDescriptionProvider.cs index ee6fe6284b3d..4c02931bf1f7 100644 --- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcJsonTranscodingDescriptionProvider.cs +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcJsonTranscodingDescriptionProvider.cs @@ -178,9 +178,43 @@ private static string ResolvePath(HttpRoutePattern httpRoutePattern, Dictionary< var routeParameter = routeParameters.SingleOrDefault(kvp => kvp.Value.RouteVariable.StartSegment == i).Value; if (routeParameter != null) { - sb.Append('{'); - sb.Append(routeParameter.JsonPath); - sb.Append('}'); + // For variables that span multiple segments (e.g., {name=widgets/*}), + // include the literal segments to make unique OpenAPI paths. + var startSegment = routeParameter.RouteVariable.StartSegment; + var endSegment = routeParameter.RouteVariable.EndSegment; + + if (endSegment - startSegment > 1) + { + // Multi-segment variable - include literal segments for uniqueness + for (var j = startSegment; j < endSegment; j++) + { + if (j > startSegment) + { + sb.Append('/'); + } + + var segment = httpRoutePattern.Segments[j]; + if (segment == "*" || segment == "**") + { + // Replace wildcard with parameter + sb.Append('{'); + sb.Append(routeParameter.JsonPath); + sb.Append('}'); + } + else + { + // Include literal segment as-is + sb.Append(segment); + } + } + } + else + { + // Single-segment variable + sb.Append('{'); + sb.Append(routeParameter.JsonPath); + sb.Append('}'); + } // Skip segments if variable is multiple segment. i = routeParameter.RouteVariable.EndSegment - 1; diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Binding/ResourcePathTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Binding/ResourcePathTests.cs new file mode 100644 index 000000000000..f8bda0f50372 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Binding/ResourcePathTests.cs @@ -0,0 +1,53 @@ +// 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.Binding; + +public class ResourcePathTests +{ + private readonly ITestOutputHelper _testOutputHelper; + + public ResourcePathTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Fact] + public void ConflictingPaths_DifferentLiteralSegments_GenerateUniquePaths() + { + // Arrange & Act + var swagger = OpenApiTestHelpers.GetOpenApiDocument(_testOutputHelper); + + // Assert - Should have 3 distinct paths + Assert.Equal(3, swagger.Paths.Count); + + // Path 1: /v1/widgets/{name} + Assert.True(swagger.Paths.ContainsKey("/v1/widgets/{name}")); + var widgetPath = swagger.Paths["/v1/widgets/{name}"]; + Assert.True(widgetPath.Operations.TryGetValue(OperationType.Get, out var widgetOperation)); + Assert.Single(widgetOperation.Parameters); + Assert.Equal(ParameterLocation.Path, widgetOperation.Parameters[0].In); + Assert.Equal("name", widgetOperation.Parameters[0].Name); + + // Path 2: /v1/things/{name} + Assert.True(swagger.Paths.ContainsKey("/v1/things/{name}")); + var thingPath = swagger.Paths["/v1/things/{name}"]; + Assert.True(thingPath.Operations.TryGetValue(OperationType.Get, out var thingOperation)); + Assert.Single(thingOperation.Parameters); + Assert.Equal(ParameterLocation.Path, thingOperation.Parameters[0].In); + Assert.Equal("name", thingOperation.Parameters[0].Name); + + // Path 3: /v1/gadgets/{name}/items/{name} (nested path) + Assert.True(swagger.Paths.ContainsKey("/v1/gadgets/{name}/items/{name}")); + var gadgetPath = swagger.Paths["/v1/gadgets/{name}/items/{name}"]; + Assert.True(gadgetPath.Operations.TryGetValue(OperationType.Get, out var gadgetOperation)); + Assert.Single(gadgetOperation.Parameters); + Assert.Equal(ParameterLocation.Path, gadgetOperation.Parameters[0].In); + Assert.Equal("name", gadgetOperation.Parameters[0].Name); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Microsoft.AspNetCore.Grpc.Swagger.Tests.csproj b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Microsoft.AspNetCore.Grpc.Swagger.Tests.csproj index 8b646b2b0074..64a40dd0842a 100644 --- a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Microsoft.AspNetCore.Grpc.Swagger.Tests.csproj +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Microsoft.AspNetCore.Grpc.Swagger.Tests.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/resources.proto b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/resources.proto new file mode 100644 index 000000000000..8dce77909925 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Proto/resources.proto @@ -0,0 +1,59 @@ +// 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"; + +import "google/api/annotations.proto"; + +package resources; + +// Service for testing Google AIP-131 style resource patterns +service ResourceService { + // Get a widget by name + rpc GetWidget (WidgetRequest) returns (WidgetResponse) { + option (google.api.http) = { + get: "/v1/{name=widgets/*}" + }; + } + + // Get a thing by name + rpc GetThing (ThingRequest) returns (ThingResponse) { + option (google.api.http) = { + get: "/v1/{name=things/*}" + }; + } + + // Get a gadget with nested path + rpc GetGadget (GadgetRequest) returns (GadgetResponse) { + option (google.api.http) = { + get: "/v1/{name=gadgets/*/items/*}" + }; + } +} + +message WidgetRequest { + string name = 1; +} + +message WidgetResponse { + string id = 1; + string name = 2; +} + +message ThingRequest { + string name = 1; +} + +message ThingResponse { + string id = 1; + string name = 2; +} + +message GadgetRequest { + string name = 1; +} + +message GadgetResponse { + string id = 1; + string name = 2; +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/ResourceService.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/ResourceService.cs new file mode 100644 index 000000000000..4ba768b19eb6 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.Swagger.Tests/Services/ResourceService.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Grpc.Swagger.Tests.Services; + +public class ResourceService : Resources.ResourceServiceBase +{ +}