Skip to content

Commit 5f3f0ed

Browse files
Merge branch 'main' into dev/css/refactor-ver-prov-factory
2 parents a227703 + 42607c5 commit 5f3f0ed

File tree

11 files changed

+383
-65
lines changed

11 files changed

+383
-65
lines changed

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ public virtual void OnProvidersExecuted( ApiDescriptionProviderContext context )
142142
else
143143
{
144144
UpdateModelTypes( result, matched );
145+
UpdateFunctionCollectionParameters( result, matched );
145146
}
146147
}
147148

@@ -456,6 +457,75 @@ private void UpdateModelTypes( ApiDescription description, IODataRoutingMetadata
456457
}
457458
}
458459

460+
private static void UpdateFunctionCollectionParameters( ApiDescription description, IODataRoutingMetadata metadata )
461+
{
462+
var parameters = description.ParameterDescriptions;
463+
464+
if ( parameters.Count == 0 )
465+
{
466+
return;
467+
}
468+
469+
var function = default( IEdmFunction );
470+
var mapping = default( IDictionary<string, string> );
471+
472+
for ( var i = 0; i < metadata.Template.Count; i++ )
473+
{
474+
var segment = metadata.Template[i];
475+
476+
if ( segment is FunctionSegmentTemplate func )
477+
{
478+
function = func.Function;
479+
mapping = func.ParameterMappings;
480+
break;
481+
}
482+
else if ( segment is FunctionImportSegmentTemplate import )
483+
{
484+
function = import.FunctionImport.Function;
485+
mapping = import.ParameterMappings;
486+
break;
487+
}
488+
}
489+
490+
if ( function is null || mapping is null )
491+
{
492+
return;
493+
}
494+
495+
var name = default( string );
496+
497+
foreach ( var parameter in function.Parameters )
498+
{
499+
if ( parameter.Type.IsCollection() &&
500+
mapping.TryGetValue( parameter.Name, out name ) &&
501+
parameters.SingleOrDefault( p => p.Name == name ) is { } param )
502+
{
503+
param.Source = BindingSource.Path;
504+
break;
505+
}
506+
}
507+
508+
var path = description.RelativePath;
509+
510+
if ( string.IsNullOrEmpty( name ) || string.IsNullOrEmpty( path ) )
511+
{
512+
return;
513+
}
514+
515+
var span = name.AsSpan();
516+
Span<char> oldValue = stackalloc char[name.Length + 2];
517+
Span<char> newValue = stackalloc char[name.Length + 4];
518+
519+
newValue[1] = oldValue[0] = '{';
520+
newValue[^2] = oldValue[^1] = '}';
521+
newValue[0] = '[';
522+
newValue[^1] = ']';
523+
span.CopyTo( oldValue.Slice( 1, name.Length ) );
524+
span.CopyTo( newValue.Slice( 2, name.Length ) );
525+
526+
description.RelativePath = path.Replace( oldValue.ToString(), newValue.ToString(), Ordinal );
527+
}
528+
459529
private sealed class ApiDescriptionComparer : IEqualityComparer<ApiDescription>
460530
{
461531
private readonly IEqualityComparer<string?> comparer = StringComparer.OrdinalIgnoreCase;

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,10 @@ private static int ODataOrder() =>
145145
new ODataApiDescriptionProvider(
146146
new StubModelMetadataProvider(),
147147
new StubModelTypeBuilder(),
148-
new OptionsFactory<ODataOptions>(
149-
Enumerable.Empty<IConfigureOptions<ODataOptions>>(),
150-
Enumerable.Empty<IPostConfigureOptions<ODataOptions>>() ),
148+
new OptionsFactory<ODataOptions>( [], [] ),
151149
Opts.Create(
152150
new ODataApiExplorerOptions(
153-
new(
154-
new StubODataApiVersionCollectionProvider(),
155-
Enumerable.Empty<IModelConfiguration>() ) ) ) ).Order;
151+
new( new StubODataApiVersionCollectionProvider(), [] ) ) ) ).Order;
156152

157153
[MethodImpl( MethodImplOptions.AggressiveInlining )]
158154
private static void MarkAsAdHoc( ODataModelBuilder builder, IEdmModel model ) =>

src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ namespace Microsoft.Extensions.DependencyInjection;
88
using Microsoft.AspNetCore.Http;
99
using Microsoft.AspNetCore.Http.Json;
1010
using Microsoft.AspNetCore.Routing;
11+
using System.Diagnostics.CodeAnalysis;
1112
using Microsoft.Extensions.DependencyInjection.Extensions;
1213
using Microsoft.Extensions.Options;
14+
using static DynamicallyAccessedMemberTypes;
1315
using static ServiceDescriptor;
1416

1517
/// <summary>
@@ -74,6 +76,65 @@ public static IApiVersioningBuilder EnableApiVersionBinding( this IApiVersioning
7476
return builder;
7577
}
7678

79+
/// <summary>
80+
/// Adds error object support in problem details.
81+
/// </summary>
82+
/// <param name="services">The <see cref="IServiceCollection">services</see> available in the application.</param>
83+
/// <param name="setup">The <see cref="JsonOptions">JSON options</see> setup <see cref="Action{T}"/> to perform, if any.</param>
84+
/// <returns>The original <paramref name="services"/>.</returns>
85+
/// <remarks>
86+
/// <para>
87+
/// This method is only intended to provide backward compatibility with previous library versions by converting
88+
/// <see cref="Microsoft.AspNetCore.Mvc.ProblemDetails"/> into Error Objects that conform to the
89+
/// <a ref="https://github.com/microsoft/api-guidelines/blob/vNext/Guidelines.md#7102-error-condition-responses">Error Responses</a>
90+
/// in the Microsoft REST API Guidelines and
91+
/// <a ref="https://docs.oasis-open.org/odata/odata-json-format/v4.01/odata-json-format-v4.01.html#_Toc38457793">OData Error Responses</a>.
92+
/// </para>
93+
/// <para>
94+
/// This method should be called before <see cref="ProblemDetailsServiceCollectionExtensions.AddProblemDetails(IServiceCollection)"/>.
95+
/// </para>
96+
/// </remarks>
97+
public static IServiceCollection AddErrorObjects( this IServiceCollection services, Action<JsonOptions>? setup = default ) =>
98+
AddErrorObjects<ErrorObjectWriter>( services, setup );
99+
100+
/// <summary>
101+
/// Adds error object support in problem details.
102+
/// </summary>
103+
/// <typeparam name="TWriter">The type of <see cref="ErrorObjectWriter"/>.</typeparam>
104+
/// <param name="services">The <see cref="IServiceCollection">services</see> available in the application.</param>
105+
/// <param name="setup">The <see cref="JsonOptions">JSON options</see> setup <see cref="Action{T}"/> to perform, if any.</param>
106+
/// <returns>The original <paramref name="services"/>.</returns>
107+
/// <remarks>
108+
/// <para>
109+
/// This method is only intended to provide backward compatibility with previous library versions by converting
110+
/// <see cref="Microsoft.AspNetCore.Mvc.ProblemDetails"/> into Error Objects that conform to the
111+
/// <a ref="https://github.com/microsoft/api-guidelines/blob/vNext/Guidelines.md#7102-error-condition-responses">Error Responses</a>
112+
/// in the Microsoft REST API Guidelines and
113+
/// <a ref="https://docs.oasis-open.org/odata/odata-json-format/v4.01/odata-json-format-v4.01.html#_Toc38457793">OData Error Responses</a>.
114+
/// </para>
115+
/// <para>
116+
/// This method should be called before <see cref="ProblemDetailsServiceCollectionExtensions.AddProblemDetails(IServiceCollection)"/>.
117+
/// </para>
118+
/// </remarks>
119+
public static IServiceCollection AddErrorObjects<[DynamicallyAccessedMembers( PublicConstructors )] TWriter>(
120+
this IServiceCollection services,
121+
Action<JsonOptions>? setup = default )
122+
where TWriter : ErrorObjectWriter
123+
{
124+
ArgumentNullException.ThrowIfNull( services );
125+
126+
services.TryAddEnumerable( Singleton<IProblemDetailsWriter, TWriter>() );
127+
services.Configure( setup ?? DefaultErrorObjectJsonConfig );
128+
129+
// TODO: remove with TryAddErrorObjectJsonOptions in 9.0+
130+
services.AddTransient<ErrorObjectsAdded>();
131+
132+
return services;
133+
}
134+
135+
private static void DefaultErrorObjectJsonConfig( JsonOptions options ) =>
136+
options.SerializerOptions.TypeInfoResolverChain.Insert( 0, ErrorObjectWriter.ErrorObjectJsonContext.Default );
137+
77138
private static void AddApiVersioningServices( IServiceCollection services )
78139
{
79140
ArgumentNullException.ThrowIfNull( services );
@@ -179,23 +240,48 @@ static Rfc7231ProblemDetailsWriter NewProblemDetailsWriter( IServiceProvider ser
179240
new( (IProblemDetailsWriter) serviceProvider.GetRequiredService( decoratedType ) );
180241
}
181242

243+
// TODO: retain for 8.1.x back-compat, but remove in 9.0+ in favor of AddErrorObjects for perf
182244
private static void TryAddErrorObjectJsonOptions( IServiceCollection services )
183245
{
184246
var serviceType = typeof( IProblemDetailsWriter );
185247
var implementationType = typeof( ErrorObjectWriter );
248+
var markerType = typeof( ErrorObjectsAdded );
249+
var hasErrorObjects = false;
250+
var hasErrorObjectsJsonConfig = false;
186251

187252
for ( var i = 0; i < services.Count; i++ )
188253
{
189254
var service = services[i];
190255

191-
// inheritance is intentionally not considered here because it will require a user-defined
192-
// JsonSerlizerContext and IConfigureOptions<JsonOptions>
193-
if ( service.ServiceType == serviceType &&
194-
service.ImplementationType == implementationType )
256+
if ( !hasErrorObjects &&
257+
service.ServiceType == serviceType &&
258+
implementationType.IsAssignableFrom( service.ImplementationType ) )
195259
{
196-
services.TryAddEnumerable( Singleton<IConfigureOptions<JsonOptions>, ErrorObjectJsonOptionsSetup>() );
197-
return;
260+
hasErrorObjects = true;
261+
262+
if ( hasErrorObjectsJsonConfig )
263+
{
264+
break;
265+
}
198266
}
267+
else if ( service.ServiceType == markerType )
268+
{
269+
hasErrorObjectsJsonConfig = true;
270+
271+
if ( hasErrorObjects )
272+
{
273+
break;
274+
}
275+
}
276+
}
277+
278+
if ( hasErrorObjects && !hasErrorObjectsJsonConfig )
279+
{
280+
services.Configure<JsonOptions>( DefaultErrorObjectJsonConfig );
199281
}
200282
}
283+
284+
// TEMP: this is a marker class to test whether Error Objects have been explicitly added. remove in 9.0+
285+
#pragma warning disable CA1812 // Avoid uninstantiated internal classes
286+
private sealed class ErrorObjectsAdded { }
201287
}

src/AspNetCore/WebApi/src/Asp.Versioning.Http/ErrorObjectJsonOptionsSetup.cs

Lines changed: 0 additions & 24 deletions
This file was deleted.

src/AspNetCore/WebApi/src/Asp.Versioning.Http/ErrorObjectWriter.cs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright (c) .NET Foundation and contributors. All rights reserved.
22

3+
// Ignore Spelling: Serializer
34
namespace Asp.Versioning;
45

56
using Microsoft.AspNetCore.Http;
@@ -30,6 +31,19 @@ public partial class ErrorObjectWriter : IProblemDetailsWriter
3031
public ErrorObjectWriter( IOptions<JsonOptions> options ) =>
3132
this.options = ( options ?? throw new ArgumentNullException( nameof( options ) ) ).Value.SerializerOptions;
3233

34+
/// <summary>
35+
/// Gets the associated, default <see cref="JsonSerializerContext"/>.
36+
/// </summary>
37+
/// <value>The associated, default <see cref="JsonSerializerContext"/>.</value>
38+
public static JsonSerializerContext DefaultJsonSerializerContext => ErrorObjectJsonContext.Default;
39+
40+
/// <summary>
41+
/// Creates and returns a new <see cref="JsonSerializerContext"/> associated with the writer.
42+
/// </summary>
43+
/// <param name="options">The <see cref="JsonSerializerOptions">JSON serializer options</see> to use.</param>
44+
/// <returns>A new <see cref="JsonSerializerContext"/>.</returns>
45+
public static JsonSerializerContext NewJsonSerializerContext( JsonSerializerOptions options ) => new ErrorObjectJsonContext( options );
46+
3347
/// <inheritdoc />
3448
public virtual bool CanWrite( ProblemDetailsContext context )
3549
{
@@ -89,6 +103,7 @@ internal ErrorObject( ProblemDetails problemDetails ) =>
89103
/// </summary>
90104
protected internal readonly partial struct ErrorDetail
91105
{
106+
private const string CodeProperty = "code";
92107
private readonly ProblemDetails problemDetails;
93108
private readonly InnerError? innerError;
94109
private readonly Dictionary<string, object> extensions = [];
@@ -103,23 +118,21 @@ internal ErrorDetail( ProblemDetails problemDetails )
103118
/// Gets or sets one of a server-defined set of error codes.
104119
/// </summary>
105120
/// <value>A server-defined error code.</value>
106-
[JsonPropertyName( "code" )]
121+
[JsonPropertyName( CodeProperty )]
107122
[JsonIgnore( Condition = WhenWritingNull )]
108123
public string? Code
109124
{
110-
get => problemDetails.Extensions.TryGetValue( "code", out var value ) &&
111-
value is string code ?
112-
code :
113-
default;
125+
get => problemDetails.Extensions.TryGetValue( CodeProperty, out var value ) &&
126+
value is string code ? code : default;
114127
set
115128
{
116129
if ( value is null )
117130
{
118-
problemDetails.Extensions.Remove( "code" );
131+
problemDetails.Extensions.Remove( CodeProperty );
119132
}
120133
else
121134
{
122-
problemDetails.Extensions["code"] = value;
135+
problemDetails.Extensions[CodeProperty] = value;
123136
}
124137
}
125138
}

src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiDescriptionExtensions.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,14 @@ public static bool TryUpdateRelativePathAndRemoveApiVersionParameter( this ApiDe
106106
return false;
107107
}
108108

109-
var token = '{' + parameter.Name + '}';
109+
Span<char> token = stackalloc char[parameter.Name.Length + 2];
110+
111+
token[0] = '{';
112+
token[^1] = '}';
113+
parameter.Name.AsSpan().CopyTo( token.Slice( 1, parameter.Name.Length ) );
114+
110115
var value = apiVersion.ToString( options.SubstitutionFormat, CultureInfo.InvariantCulture );
111-
var newRelativePath = relativePath.Replace( token, value, StringComparison.Ordinal );
116+
var newRelativePath = relativePath.Replace( token.ToString(), value, StringComparison.Ordinal );
112117

113118
if ( relativePath == newRelativePath )
114119
{

src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionParameterDescriptionContext.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ namespace Asp.Versioning.ApiExplorer;
99
using Microsoft.AspNetCore.Routing;
1010
using Microsoft.AspNetCore.Routing.Patterns;
1111
using Microsoft.AspNetCore.Routing.Template;
12+
using System.Runtime.CompilerServices;
1213
using static Asp.Versioning.ApiVersionParameterLocation;
1314
using static System.Linq.Enumerable;
1415
using static System.StringComparison;
@@ -304,7 +305,7 @@ routeInfo.Constraints is IEnumerable<IRouteConstraint> constraints &&
304305
continue;
305306
}
306307

307-
var token = $"{parameter.Name}:{constraintName}";
308+
var token = FormatToken( parameter.Name, constraintName );
308309

309310
parameterDescription.Name = parameter.Name;
310311
description.RelativePath = relativePath.Replace( token, parameter.Name, Ordinal );
@@ -375,7 +376,7 @@ routeInfo.Constraints is IEnumerable<IRouteConstraint> constraints &&
375376
},
376377
Source = BindingSource.Path,
377378
};
378-
var token = $"{parameter.Name}:{constraintName}";
379+
var token = FormatToken( parameter.Name!, constraintName! );
379380

380381
description.RelativePath = relativePath.Replace( token, parameter.Name, Ordinal );
381382
description.ParameterDescriptions.Insert( 0, result );
@@ -457,4 +458,18 @@ private static bool FirstParameterIsOptional(
457458

458459
return apiVersion == defaultApiVersion;
459460
}
461+
462+
[MethodImpl( MethodImplOptions.AggressiveInlining )]
463+
private static string FormatToken( ReadOnlySpan<char> parameterName, ReadOnlySpan<char> constraintName )
464+
{
465+
var left = parameterName.Length;
466+
var right = constraintName.Length;
467+
Span<char> token = stackalloc char[left + right + 1];
468+
469+
parameterName.CopyTo( token[..left] );
470+
token[left] = ':';
471+
constraintName.CopyTo( token.Slice( left + 1, right ) );
472+
473+
return token.ToString();
474+
}
460475
}

0 commit comments

Comments
 (0)