From 8ef855763edfbc94bb18ca3e9726c7e297a15eb5 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sat, 13 Dec 2025 19:39:10 -0800 Subject: [PATCH] Single-quote OData strings within parentheses syntax. Resolves #1152. Related to #1138. --- .../ODataApiDescriptionProvider.cs | 118 +++++++++++++++++- .../ODataApiDescriptionProviderTest.cs | 22 ++-- .../Configuration/AllConfigurations.cs | 1 + .../Configuration/RecordModelConfiguration.cs | 24 ++++ .../Simulators/FunctionsController.cs | 9 ++ .../Simulators/Models/Record.cs | 10 ++ .../Simulators/V1/RecordsController.cs | 34 +++++ 7 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Configuration/RecordModelConfiguration.cs create mode 100644 src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Models/Record.cs create mode 100644 src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/V1/RecordsController.cs diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs index fdb4ff8d..8254d7d1 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs @@ -13,10 +13,12 @@ namespace Asp.Versioning.ApiExplorer; using Microsoft.AspNetCore.OData.Routing.Template; using Microsoft.Extensions.Options; using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; -using static System.StringComparison; +using System.Text; using static ODataMetadataOptions; +using static System.StringComparison; using Opts = Microsoft.Extensions.Options.Options; /// @@ -144,6 +146,8 @@ public virtual void OnProvidersExecuted( ApiDescriptionProviderContext context ) UpdateModelTypes( result, matched ); UpdateFunctionCollectionParameters( result, matched ); } + + QuoteStringParameters( result ); } if ( results.Count > 0 ) @@ -526,6 +530,118 @@ private static void UpdateFunctionCollectionParameters( ApiDescription descripti description.RelativePath = path.Replace( oldValue.ToString(), newValue.ToString(), Ordinal ); } + private static void AddStringParameterNames( + IEdmFunction function, + IDictionary parameterMappings, + ref HashSet? names ) + { + foreach ( var parameter in function.Parameters ) + { + if ( parameter.Type.IsString() ) + { + names ??= []; + names.Add( parameterMappings[parameter.Name] ); + } + } + } + + private static void QuoteStringParameters( ApiDescription description ) + { + if ( description.RelativePath is not string path ) + { + return; + } + + var action = description.ActionDescriptor; + var metadata = action.EndpointMetadata; + var names = default( HashSet? ); + + for ( var i = metadata.Count - 1; i >= 0; i-- ) + { + if ( metadata[i] is not IODataRoutingMetadata odata ) + { + continue; + } + + for ( var j = 0; j < odata.Template.Count; j++ ) + { + switch ( odata.Template[j] ) + { + case KeySegmentTemplate key when key.KeyProperties.Count > 1: + foreach ( (var name, var property) in key.KeyProperties ) + { + if ( property.Type.IsString() ) + { + names ??= []; + names.Add( key.KeyMappings[name] ); + } + } + + break; + + case FunctionSegmentTemplate function when function.ParameterMappings.Count > 0: + AddStringParameterNames( function.Function, function.ParameterMappings, ref names ); + break; + + case FunctionImportSegmentTemplate function when function.ParameterMappings.Count > 0: + AddStringParameterNames( function.FunctionImport.Function, function.ParameterMappings, ref names ); + break; + } + } + + break; + } + + if ( names is null ) + { + return; + } + + var capacity = path.Length + ( names.Count << 1 ); + var template = new StringBuilder( path, capacity ); + var position = 0; + var inParens = false; + + while ( position < template.Length ) + { + switch ( template[position++] ) + { + case '(': + inParens = true; + continue; + case ')': + inParens = false; + continue; + case '{': + break; + default: + continue; + } + + var start = position; + + while ( position < template.Length && template[position] != '}' ) + { + position++; + } + + if ( inParens && position < template.Length ) + { + var end = position; + var name = template.ToString( start, end - start ); + + if ( names.Contains( name ) ) + { + template.Insert( start - 1, '\'' ); + template.Insert( end + 2, '\'' ); + position += 2; + } + } + } + + description.RelativePath = template.ToString(); + } + private sealed class ApiDescriptionComparer : IEqualityComparer { private readonly IEqualityComparer comparer = StringComparer.OrdinalIgnoreCase; diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/ApiExplorer/ODataApiDescriptionProviderTest.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/ApiExplorer/ODataApiDescriptionProviderTest.cs index 532abb00..96dc6ef8 100644 --- a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/ApiExplorer/ODataApiDescriptionProviderTest.cs +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/ApiExplorer/ODataApiDescriptionProviderTest.cs @@ -125,12 +125,12 @@ private void AssertVersion0_9( ApiDescriptionGroup group ) PrintGroup( items ); group.GroupName.Should().Be( GroupName ); items.Should().BeEquivalentTo( - new[] - { + [ + new { HttpMethod = "GET", GroupName, RelativePath = "api/GetHash(Input='{input}')" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/GetSalesTaxRate(PostalCode={postalCode})" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders/{key}" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/People/{key}" }, - }, + ], options => options.ExcludingMissingMembers() ); } @@ -142,16 +142,19 @@ private void AssertVersion1( ApiDescriptionGroup group ) PrintGroup( items ); group.GroupName.Should().Be( GroupName ); items.Should().BeEquivalentTo( - new[] - { + [ new { HttpMethod = "GET", GroupName, RelativePath = "api/Books" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/Books/{id}" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/GetHash(Input='{input}')" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/GetSalesTaxRate(PostalCode={postalCode})" }, new { HttpMethod = "POST", GroupName, RelativePath = "api/Orders" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders/{key}" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders/MostExpensive" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/People/{key}" }, - }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Records(id='{id}', source={source})" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Records" }, + new { HttpMethod = "GET", GroupName, RelativePath = "api/Records/$count" }, + ], options => options.ExcludingMissingMembers() ); AssertQueryOptionWithoutOData( items[0], "filter", "author", "published" ); @@ -165,8 +168,8 @@ private void AssertVersion2( ApiDescriptionGroup group ) PrintGroup( items ); group.GroupName.Should().Be( GroupName ); items.Should().BeEquivalentTo( - new[] - { + [ + new { HttpMethod = "GET", GroupName, RelativePath = "api/GetHash(Input='{input}')" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/GetSalesTaxRate(PostalCode={postalCode})" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders" }, new { HttpMethod = "POST", GroupName, RelativePath = "api/Orders" }, @@ -179,7 +182,7 @@ private void AssertVersion2( ApiDescriptionGroup group ) new { HttpMethod = "GET", GroupName, RelativePath = "api/People/{key}" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/People/$count" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/People/NewHires(Since={since})" }, - }, + ], options => options.ExcludingMissingMembers() ); } @@ -189,6 +192,7 @@ private void AssertVersion3( ApiDescriptionGroup group ) var items = group.Items.OrderBy( i => i.RelativePath ).ThenBy( i => i.HttpMethod ).ToArray(); var expected = new[] { + new { HttpMethod = "GET", GroupName, RelativePath = "api/GetHash(Input='{input}')" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/GetSalesTaxRate(PostalCode={postalCode})" }, new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders" }, new { HttpMethod = "POST", GroupName, RelativePath = "api/Orders" }, diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Configuration/AllConfigurations.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Configuration/AllConfigurations.cs index a70422b7..02aa8258 100644 --- a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Configuration/AllConfigurations.cs +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Configuration/AllConfigurations.cs @@ -16,5 +16,6 @@ public void Apply( ODataModelBuilder builder, ApiVersion apiVersion, string rout ArgumentNullException.ThrowIfNull( builder ); builder.Function( "GetSalesTaxRate" ).Returns().Parameter( "PostalCode" ); + builder.Function( "GetHash" ).Returns().Parameter( "Input" ); } } \ No newline at end of file diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Configuration/RecordModelConfiguration.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Configuration/RecordModelConfiguration.cs new file mode 100644 index 00000000..aea97c07 --- /dev/null +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Configuration/RecordModelConfiguration.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Simulators.Configuration; + +using Asp.Versioning.OData; +using Asp.Versioning.Simulators.Models; +using Microsoft.OData.ModelBuilder; + +/// +/// Represents the model configuration for records. +/// +public class RecordModelConfiguration : IModelConfiguration +{ + /// + public void Apply( ODataModelBuilder builder, ApiVersion apiVersion, string routePrefix ) + { + ArgumentNullException.ThrowIfNull( builder ); + + if ( apiVersion == ApiVersions.V1 ) + { + builder.EntitySet( "Records" ).EntityType.HasKey( r => new { r.Id, r.Source } ); + } + } +} \ No newline at end of file diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/FunctionsController.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/FunctionsController.cs index 84598d06..15798b31 100644 --- a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/FunctionsController.cs +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/FunctionsController.cs @@ -22,4 +22,13 @@ public class FunctionsController : ODataController [HttpGet( "api/GetSalesTaxRate(PostalCode={postalCode})" )] [ProducesResponseType( typeof( double ), Status200OK )] public IActionResult GetSalesTaxRate( int postalCode ) => Ok( 5.6 ); + + /// + /// Computes the hash of the specified text. + /// + /// The text to hash. + /// The hash of the input string. + [HttpGet( "api/GetHash(Input={input})" )] + [ProducesResponseType( typeof( int ), Status200OK )] + public IActionResult GetHash( string input ) => Ok( input.GetHashCode() ); } \ No newline at end of file diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Models/Record.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Models/Record.cs new file mode 100644 index 00000000..1737c44c --- /dev/null +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Models/Record.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Simulators.Models; + +public class Record +{ + public string Id { get; set; } + + public int Source { get; set; } +} \ No newline at end of file diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/V1/RecordsController.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/V1/RecordsController.cs new file mode 100644 index 00000000..9fd7f4b2 --- /dev/null +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/V1/RecordsController.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Simulators.V1; + +using Asp.Versioning.Simulators.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Routing.Controllers; +using static Microsoft.AspNetCore.Http.StatusCodes; + +/// +/// Represents a RESTful record service. +/// +[ApiVersion( 1.0 )] +public class RecordsController : ODataController +{ + /// + /// Gets a single record. + /// + /// The record identifier. + /// The record source identifier. + /// The requested record. + /// The record was successfully retrieved. + /// The record does not exist. + [HttpGet( "api/Records(id={id}, source={source})" )] + [Produces( "application/json" )] + [ProducesResponseType( typeof( Record ), Status200OK )] + [ProducesResponseType( Status404NotFound )] + public IActionResult Get( string id, int source ) => + Ok( new Record() + { + Id = id, + Source = source, + } ); +} \ No newline at end of file