Skip to content

Commit 8ef8557

Browse files
Single-quote OData strings within parentheses syntax. Resolves #1152. Related to #1138.
1 parent bd469de commit 8ef8557

File tree

7 files changed

+208
-10
lines changed

7 files changed

+208
-10
lines changed

src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiDescriptionProvider.cs

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ namespace Asp.Versioning.ApiExplorer;
1313
using Microsoft.AspNetCore.OData.Routing.Template;
1414
using Microsoft.Extensions.Options;
1515
using Microsoft.OData.Edm;
16+
using Microsoft.OData.UriParser;
1617
using System.Diagnostics.CodeAnalysis;
1718
using System.Runtime.CompilerServices;
18-
using static System.StringComparison;
19+
using System.Text;
1920
using static ODataMetadataOptions;
21+
using static System.StringComparison;
2022
using Opts = Microsoft.Extensions.Options.Options;
2123

2224
/// <summary>
@@ -144,6 +146,8 @@ public virtual void OnProvidersExecuted( ApiDescriptionProviderContext context )
144146
UpdateModelTypes( result, matched );
145147
UpdateFunctionCollectionParameters( result, matched );
146148
}
149+
150+
QuoteStringParameters( result );
147151
}
148152

149153
if ( results.Count > 0 )
@@ -526,6 +530,118 @@ private static void UpdateFunctionCollectionParameters( ApiDescription descripti
526530
description.RelativePath = path.Replace( oldValue.ToString(), newValue.ToString(), Ordinal );
527531
}
528532

533+
private static void AddStringParameterNames(
534+
IEdmFunction function,
535+
IDictionary<string, string> parameterMappings,
536+
ref HashSet<string>? names )
537+
{
538+
foreach ( var parameter in function.Parameters )
539+
{
540+
if ( parameter.Type.IsString() )
541+
{
542+
names ??= [];
543+
names.Add( parameterMappings[parameter.Name] );
544+
}
545+
}
546+
}
547+
548+
private static void QuoteStringParameters( ApiDescription description )
549+
{
550+
if ( description.RelativePath is not string path )
551+
{
552+
return;
553+
}
554+
555+
var action = description.ActionDescriptor;
556+
var metadata = action.EndpointMetadata;
557+
var names = default( HashSet<string>? );
558+
559+
for ( var i = metadata.Count - 1; i >= 0; i-- )
560+
{
561+
if ( metadata[i] is not IODataRoutingMetadata odata )
562+
{
563+
continue;
564+
}
565+
566+
for ( var j = 0; j < odata.Template.Count; j++ )
567+
{
568+
switch ( odata.Template[j] )
569+
{
570+
case KeySegmentTemplate key when key.KeyProperties.Count > 1:
571+
foreach ( (var name, var property) in key.KeyProperties )
572+
{
573+
if ( property.Type.IsString() )
574+
{
575+
names ??= [];
576+
names.Add( key.KeyMappings[name] );
577+
}
578+
}
579+
580+
break;
581+
582+
case FunctionSegmentTemplate function when function.ParameterMappings.Count > 0:
583+
AddStringParameterNames( function.Function, function.ParameterMappings, ref names );
584+
break;
585+
586+
case FunctionImportSegmentTemplate function when function.ParameterMappings.Count > 0:
587+
AddStringParameterNames( function.FunctionImport.Function, function.ParameterMappings, ref names );
588+
break;
589+
}
590+
}
591+
592+
break;
593+
}
594+
595+
if ( names is null )
596+
{
597+
return;
598+
}
599+
600+
var capacity = path.Length + ( names.Count << 1 );
601+
var template = new StringBuilder( path, capacity );
602+
var position = 0;
603+
var inParens = false;
604+
605+
while ( position < template.Length )
606+
{
607+
switch ( template[position++] )
608+
{
609+
case '(':
610+
inParens = true;
611+
continue;
612+
case ')':
613+
inParens = false;
614+
continue;
615+
case '{':
616+
break;
617+
default:
618+
continue;
619+
}
620+
621+
var start = position;
622+
623+
while ( position < template.Length && template[position] != '}' )
624+
{
625+
position++;
626+
}
627+
628+
if ( inParens && position < template.Length )
629+
{
630+
var end = position;
631+
var name = template.ToString( start, end - start );
632+
633+
if ( names.Contains( name ) )
634+
{
635+
template.Insert( start - 1, '\'' );
636+
template.Insert( end + 2, '\'' );
637+
position += 2;
638+
}
639+
}
640+
}
641+
642+
description.RelativePath = template.ToString();
643+
}
644+
529645
private sealed class ApiDescriptionComparer : IEqualityComparer<ApiDescription>
530646
{
531647
private readonly IEqualityComparer<string?> comparer = StringComparer.OrdinalIgnoreCase;

src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/ApiExplorer/ODataApiDescriptionProviderTest.cs

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,12 @@ private void AssertVersion0_9( ApiDescriptionGroup group )
125125
PrintGroup( items );
126126
group.GroupName.Should().Be( GroupName );
127127
items.Should().BeEquivalentTo(
128-
new[]
129-
{
128+
[
129+
new { HttpMethod = "GET", GroupName, RelativePath = "api/GetHash(Input='{input}')" },
130130
new { HttpMethod = "GET", GroupName, RelativePath = "api/GetSalesTaxRate(PostalCode={postalCode})" },
131131
new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders/{key}" },
132132
new { HttpMethod = "GET", GroupName, RelativePath = "api/People/{key}" },
133-
},
133+
],
134134
options => options.ExcludingMissingMembers() );
135135
}
136136

@@ -142,16 +142,19 @@ private void AssertVersion1( ApiDescriptionGroup group )
142142
PrintGroup( items );
143143
group.GroupName.Should().Be( GroupName );
144144
items.Should().BeEquivalentTo(
145-
new[]
146-
{
145+
[
147146
new { HttpMethod = "GET", GroupName, RelativePath = "api/Books" },
148147
new { HttpMethod = "GET", GroupName, RelativePath = "api/Books/{id}" },
148+
new { HttpMethod = "GET", GroupName, RelativePath = "api/GetHash(Input='{input}')" },
149149
new { HttpMethod = "GET", GroupName, RelativePath = "api/GetSalesTaxRate(PostalCode={postalCode})" },
150150
new { HttpMethod = "POST", GroupName, RelativePath = "api/Orders" },
151151
new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders/{key}" },
152152
new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders/MostExpensive" },
153153
new { HttpMethod = "GET", GroupName, RelativePath = "api/People/{key}" },
154-
},
154+
new { HttpMethod = "GET", GroupName, RelativePath = "api/Records(id='{id}', source={source})" },
155+
new { HttpMethod = "GET", GroupName, RelativePath = "api/Records" },
156+
new { HttpMethod = "GET", GroupName, RelativePath = "api/Records/$count" },
157+
],
155158
options => options.ExcludingMissingMembers() );
156159

157160
AssertQueryOptionWithoutOData( items[0], "filter", "author", "published" );
@@ -165,8 +168,8 @@ private void AssertVersion2( ApiDescriptionGroup group )
165168
PrintGroup( items );
166169
group.GroupName.Should().Be( GroupName );
167170
items.Should().BeEquivalentTo(
168-
new[]
169-
{
171+
[
172+
new { HttpMethod = "GET", GroupName, RelativePath = "api/GetHash(Input='{input}')" },
170173
new { HttpMethod = "GET", GroupName, RelativePath = "api/GetSalesTaxRate(PostalCode={postalCode})" },
171174
new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders" },
172175
new { HttpMethod = "POST", GroupName, RelativePath = "api/Orders" },
@@ -179,7 +182,7 @@ private void AssertVersion2( ApiDescriptionGroup group )
179182
new { HttpMethod = "GET", GroupName, RelativePath = "api/People/{key}" },
180183
new { HttpMethod = "GET", GroupName, RelativePath = "api/People/$count" },
181184
new { HttpMethod = "GET", GroupName, RelativePath = "api/People/NewHires(Since={since})" },
182-
},
185+
],
183186
options => options.ExcludingMissingMembers() );
184187
}
185188

@@ -189,6 +192,7 @@ private void AssertVersion3( ApiDescriptionGroup group )
189192
var items = group.Items.OrderBy( i => i.RelativePath ).ThenBy( i => i.HttpMethod ).ToArray();
190193
var expected = new[]
191194
{
195+
new { HttpMethod = "GET", GroupName, RelativePath = "api/GetHash(Input='{input}')" },
192196
new { HttpMethod = "GET", GroupName, RelativePath = "api/GetSalesTaxRate(PostalCode={postalCode})" },
193197
new { HttpMethod = "GET", GroupName, RelativePath = "api/Orders" },
194198
new { HttpMethod = "POST", GroupName, RelativePath = "api/Orders" },

src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Configuration/AllConfigurations.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ public void Apply( ODataModelBuilder builder, ApiVersion apiVersion, string rout
1616
ArgumentNullException.ThrowIfNull( builder );
1717

1818
builder.Function( "GetSalesTaxRate" ).Returns<double>().Parameter<int>( "PostalCode" );
19+
builder.Function( "GetHash" ).Returns<int>().Parameter<string>( "Input" );
1920
}
2021
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) .NET Foundation and contributors. All rights reserved.
2+
3+
namespace Asp.Versioning.Simulators.Configuration;
4+
5+
using Asp.Versioning.OData;
6+
using Asp.Versioning.Simulators.Models;
7+
using Microsoft.OData.ModelBuilder;
8+
9+
/// <summary>
10+
/// Represents the model configuration for records.
11+
/// </summary>
12+
public class RecordModelConfiguration : IModelConfiguration
13+
{
14+
/// <inheritdoc />
15+
public void Apply( ODataModelBuilder builder, ApiVersion apiVersion, string routePrefix )
16+
{
17+
ArgumentNullException.ThrowIfNull( builder );
18+
19+
if ( apiVersion == ApiVersions.V1 )
20+
{
21+
builder.EntitySet<Record>( "Records" ).EntityType.HasKey( r => new { r.Id, r.Source } );
22+
}
23+
}
24+
}

src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/FunctionsController.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,13 @@ public class FunctionsController : ODataController
2222
[HttpGet( "api/GetSalesTaxRate(PostalCode={postalCode})" )]
2323
[ProducesResponseType( typeof( double ), Status200OK )]
2424
public IActionResult GetSalesTaxRate( int postalCode ) => Ok( 5.6 );
25+
26+
/// <summary>
27+
/// Computes the hash of the specified text.
28+
/// </summary>
29+
/// <param name="input">The text to hash.</param>
30+
/// <returns>The hash of the input string.</returns>
31+
[HttpGet( "api/GetHash(Input={input})" )]
32+
[ProducesResponseType( typeof( int ), Status200OK )]
33+
public IActionResult GetHash( string input ) => Ok( input.GetHashCode() );
2534
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright (c) .NET Foundation and contributors. All rights reserved.
2+
3+
namespace Asp.Versioning.Simulators.Models;
4+
5+
public class Record
6+
{
7+
public string Id { get; set; }
8+
9+
public int Source { get; set; }
10+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) .NET Foundation and contributors. All rights reserved.
2+
3+
namespace Asp.Versioning.Simulators.V1;
4+
5+
using Asp.Versioning.Simulators.Models;
6+
using Microsoft.AspNetCore.Mvc;
7+
using Microsoft.AspNetCore.OData.Routing.Controllers;
8+
using static Microsoft.AspNetCore.Http.StatusCodes;
9+
10+
/// <summary>
11+
/// Represents a RESTful record service.
12+
/// </summary>
13+
[ApiVersion( 1.0 )]
14+
public class RecordsController : ODataController
15+
{
16+
/// <summary>
17+
/// Gets a single record.
18+
/// </summary>
19+
/// <param name="id">The record identifier.</param>
20+
/// <param name="source">The record source identifier.</param>
21+
/// <returns>The requested record.</returns>
22+
/// <response code="200">The record was successfully retrieved.</response>
23+
/// <response code="404">The record does not exist.</response>
24+
[HttpGet( "api/Records(id={id}, source={source})" )]
25+
[Produces( "application/json" )]
26+
[ProducesResponseType( typeof( Record ), Status200OK )]
27+
[ProducesResponseType( Status404NotFound )]
28+
public IActionResult Get( string id, int source ) =>
29+
Ok( new Record()
30+
{
31+
Id = id,
32+
Source = source,
33+
} );
34+
}

0 commit comments

Comments
 (0)