From 15d248cd09e56153e3e5e8401330b44e1842bc6f Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Mon, 5 Dec 2022 09:47:19 -0800 Subject: [PATCH 1/8] Refactor to make registering model configurations public --- .../IApiVersioningBuilderExtensions.cs | 42 +--------- .../IServiceCollectionExtensions.cs | 76 +++++++++++++++++++ ...ODataMultiModelApplicationModelProvider.cs | 1 - 3 files changed, 79 insertions(+), 40 deletions(-) create mode 100644 src/AspNetCore/OData/src/Asp.Versioning.OData/DependencyInjection/IServiceCollectionExtensions.cs diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/DependencyInjection/IApiVersioningBuilderExtensions.cs index 3af0b775..79bba8dd 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -9,7 +9,6 @@ namespace Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.AspNetCore.OData; using Microsoft.AspNetCore.OData.Routing.Template; using Microsoft.AspNetCore.Routing; @@ -66,8 +65,7 @@ private static void AddServices( IServiceCollection services ) services.TryRemoveODataService( typeof( IApplicationModelProvider ), ODataRoutingApplicationModelProviderType ); var partManager = services.GetOrCreateApplicationPartManager(); - - ConfigureDefaultFeatureProviders( partManager ); + var configured = partManager.ConfigureDefaultFeatureProviders(); services.AddHttpContextAccessor(); services.TryAddSingleton(); @@ -84,24 +82,11 @@ private static void AddServices( IServiceCollection services ) services.TryAddEnumerable( Singleton() ); services.TryAddEnumerable( Transient() ); services.TryAddEnumerable( Transient() ); - services.AddModelConfigurationsAsServices( partManager ); - } - - private static T GetService( this IServiceCollection services ) => - (T) services.LastOrDefault( d => d.ServiceType == typeof( T ) )?.ImplementationInstance!; - - private static ApplicationPartManager GetOrCreateApplicationPartManager( this IServiceCollection services ) - { - var partManager = services.GetService(); - if ( partManager == null ) + if ( configured ) { - partManager = new ApplicationPartManager(); - services.TryAddSingleton( partManager ); + services.AddModelConfigurationsAsServices( partManager ); } - - partManager.ApplicationParts.Add( new AssemblyPart( typeof( ODataApiVersioningOptions ).Assembly ) ); - return partManager; } [MethodImpl( MethodImplOptions.AggressiveInlining )] @@ -148,27 +133,6 @@ private static void TryReplaceODataService( } } - private static void AddModelConfigurationsAsServices( this IServiceCollection services, ApplicationPartManager partManager ) - { - var feature = new ModelConfigurationFeature(); - var modelConfigurationType = typeof( IModelConfiguration ); - - partManager.PopulateFeature( feature ); - - foreach ( var modelConfiguration in feature.ModelConfigurations ) - { - services.TryAddEnumerable( Transient( modelConfigurationType, modelConfiguration ) ); - } - } - - private static void ConfigureDefaultFeatureProviders( ApplicationPartManager partManager ) - { - if ( !partManager.FeatureProviders.OfType().Any() ) - { - partManager.FeatureProviders.Add( new ModelConfigurationFeatureProvider() ); - } - } - private static object CreateInstance( this IServiceProvider services, ServiceDescriptor descriptor ) { if ( descriptor.ImplementationInstance != null ) diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/DependencyInjection/IServiceCollectionExtensions.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/DependencyInjection/IServiceCollectionExtensions.cs new file mode 100644 index 00000000..cb86af84 --- /dev/null +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/DependencyInjection/IServiceCollectionExtensions.cs @@ -0,0 +1,76 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Microsoft.Extensions.DependencyInjection; + +using Asp.Versioning.OData; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection.Extensions; +using System.Runtime.CompilerServices; +using static Microsoft.Extensions.DependencyInjection.ServiceDescriptor; + +/// +/// Provides extension methods for . +/// +public static class IServiceCollectionExtensions +{ + [MethodImpl( MethodImplOptions.AggressiveInlining )] + internal static T GetService( this IServiceCollection services ) => + (T) services.LastOrDefault( d => d.ServiceType == typeof( T ) )?.ImplementationInstance!; + + internal static ApplicationPartManager GetOrCreateApplicationPartManager( this IServiceCollection services ) + { + var partManager = services.GetService(); + + if ( partManager == null ) + { + partManager = new ApplicationPartManager(); + services.TryAddSingleton( partManager ); + } + + partManager.ApplicationParts.Add( new AssemblyPart( typeof( ODataApiVersioningOptions ).Assembly ) ); + return partManager; + } + + internal static void AddModelConfigurationsAsServices( this IServiceCollection services, ApplicationPartManager partManager ) + { + var feature = new ModelConfigurationFeature(); + var modelConfigurationType = typeof( IModelConfiguration ); + + partManager.PopulateFeature( feature ); + + foreach ( var modelConfiguration in feature.ModelConfigurations ) + { + services.TryAddEnumerable( Transient( modelConfigurationType, modelConfiguration ) ); + } + } + + internal static bool ConfigureDefaultFeatureProviders( this ApplicationPartManager partManager ) + { + if ( partManager.FeatureProviders.OfType().Any() ) + { + return false; + } + + partManager.FeatureProviders.Add( new ModelConfigurationFeatureProvider() ); + return true; + } + + /// + /// Registers discovered model configurations as services in the . + /// + /// The extended . + public static void AddModelConfigurationsAsServices( this IServiceCollection services ) + { + if ( services == null ) + { + throw new ArgumentNullException( nameof( services ) ); + } + + var partManager = services.GetOrCreateApplicationPartManager(); + + if ( ConfigureDefaultFeatureProviders( partManager ) ) + { + services.AddModelConfigurationsAsServices( partManager ); + } + } +} \ No newline at end of file diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs index c8fc3fe0..f0189cb0 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs @@ -4,7 +4,6 @@ namespace Asp.Versioning.OData; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.OData; using Microsoft.AspNetCore.OData.Routing.Conventions; From c6f37f5ae9b806002d0b6ebd84a9d3806bb997ff Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Mon, 5 Dec 2022 19:22:40 -0800 Subject: [PATCH 2/8] Fix NETFX reference assemblies --- asp.sln | 3 --- build/test.targets | 2 +- .../Asp.Versioning.Abstractions.Tests.csproj | 2 +- src/AspNet/Directory.Build.targets | 10 ---------- src/Directory.Build.targets | 4 ++++ 5 files changed, 6 insertions(+), 15 deletions(-) delete mode 100644 src/AspNet/Directory.Build.targets diff --git a/asp.sln b/asp.sln index 57868d94..0df9e4ed 100644 --- a/asp.sln +++ b/asp.sln @@ -26,9 +26,6 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Abstractions", "Abstractions", "{7B0FA6C2-47BA-4C34-90E0-B75DF44F2124}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AspNet", "AspNet", "{34A0373F-12C9-44A8-9A1C-5EEE7218C877}" - ProjectSection(SolutionItems) = preProject - src\AspNet\Directory.Build.targets = src\AspNet\Directory.Build.targets - EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AspNetCore", "AspNetCore", "{EBC9F217-E8BC-4DCE-9C67-F22150959EAF}" EndProject diff --git a/build/test.targets b/build/test.targets index 286e53f6..8616ce63 100644 --- a/build/test.targets +++ b/build/test.targets @@ -3,7 +3,7 @@ 6.8.0 - 4.18.2 + 4.18.3 2.4.5 diff --git a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/Asp.Versioning.Abstractions.Tests.csproj b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/Asp.Versioning.Abstractions.Tests.csproj index a7c04d59..668e879c 100644 --- a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/Asp.Versioning.Abstractions.Tests.csproj +++ b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/Asp.Versioning.Abstractions.Tests.csproj @@ -1,6 +1,6 @@  - net7.0;net472;net452 + net7.0;net452;net472 Asp.Versioning diff --git a/src/AspNet/Directory.Build.targets b/src/AspNet/Directory.Build.targets deleted file mode 100644 index 6a993f01..00000000 --- a/src/AspNet/Directory.Build.targets +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 15a2e2dc..468f1f01 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -21,6 +21,10 @@ + + + + From 7da347551a9cd3a05b081e35d65b5683124e1983 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Mon, 5 Dec 2022 19:26:47 -0800 Subject: [PATCH 3/8] Add ad hoc annotation and resolve annotations via extension method --- .../OData/EdmModelSelector.cs | 6 +- .../OData/VersionedODataModelBuilderTest.cs | 2 +- .../ODataApiDescriptionProvider.cs | 2 +- .../ODataQueryOptionDescriptionContext.cs | 2 +- ...ODataMultiModelApplicationModelProvider.cs | 6 +- .../OData/VersionedODataTemplateTranslator.cs | 2 +- .../VersionedAttributeRoutingConvention.cs | 2 +- .../OData/VersionedODataModelBuilderTest.cs | 2 +- .../OData/DefaultModelTypeBuilder.cs | 55 ++++++++++++++----- .../OData/TypeExtensions.cs | 33 +++++------ .../OData/TypeSubstitutionContext.cs | 2 +- .../IEdmModelExtensions.cs | 28 ++++++++++ .../src/Common.OData/OData/AdHocAnnotation.cs | 15 +++++ 13 files changed, 113 insertions(+), 44 deletions(-) create mode 100644 src/Common/src/Common.OData/Microsoft.OData.Edm/IEdmModelExtensions.cs create mode 100644 src/Common/src/Common.OData/OData/AdHocAnnotation.cs diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/OData/EdmModelSelector.cs b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/OData/EdmModelSelector.cs index cba6623f..f8f7e17b 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/OData/EdmModelSelector.cs +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData/OData/EdmModelSelector.cs @@ -130,16 +130,12 @@ public EdmModelSelector( IEnumerable models, IApiVersionSelector apiV private static void AddVersionFromModel( IEdmModel model, IList versions, IDictionary collection ) { - var annotation = model.GetAnnotationValue( model ); - - if ( annotation == null ) + if ( model.GetApiVersion() is not ApiVersion version ) { var message = string.Format( CultureInfo.CurrentCulture, SR.MissingAnnotation, typeof( ApiVersionAnnotation ).Name ); throw new ArgumentException( message ); } - var version = annotation.ApiVersion; - collection.Add( version, model ); versions.Add( version ); } diff --git a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.Tests/OData/VersionedODataModelBuilderTest.cs b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.Tests/OData/VersionedODataModelBuilderTest.cs index 7bd0781e..31845519 100644 --- a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.Tests/OData/VersionedODataModelBuilderTest.cs +++ b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.Tests/OData/VersionedODataModelBuilderTest.cs @@ -34,7 +34,7 @@ public void get_edm_models_should_return_expected_results() var model = builder.GetEdmModels().Single(); // assert - model.GetAnnotationValue( model ).ApiVersion.Should().Be( apiVersion ); + model.GetApiVersion().Should().Be( apiVersion ); modelCreated.Verify( f => f( It.IsAny(), model ), Times.Once() ); } 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 0084cd01..06611d5b 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 @@ -221,7 +221,7 @@ private static bool TryMatchModelVersion( for ( var i = 0; i < items.Count; i++ ) { var item = items[i]; - var otherApiVersion = item.Model.GetAnnotationValue( item.Model )?.ApiVersion; + var otherApiVersion = item.Model.GetApiVersion(); if ( apiVersion.Equals( otherApiVersion ) ) { diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionDescriptionContext.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionDescriptionContext.cs index aef2fdbf..ae3ad97b 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionDescriptionContext.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ODataQueryOptionDescriptionContext.cs @@ -34,7 +34,7 @@ public partial class ODataQueryOptionDescriptionContext foreach ( var item in items ) { var model = item.Model; - var otherVersion = model.GetAnnotationValue( model )?.ApiVersion; + var otherVersion = model.GetApiVersion(); if ( version.Equals( otherVersion ) ) { diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs index f0189cb0..7fb591e2 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/ODataMultiModelApplicationModelProvider.cs @@ -152,7 +152,11 @@ private void AddRouteComponents( for ( var i = 0; i < models.Count; i++ ) { var model = models[i]; - var version = model.GetAnnotationValue( model ).ApiVersion; + + if ( model.GetApiVersion() is not ApiVersion version ) + { + continue; + } if ( !mappings.TryGetValue( version, out var options ) ) { diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/VersionedODataTemplateTranslator.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/VersionedODataTemplateTranslator.cs index 5d58b2b5..8f29de7b 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/VersionedODataTemplateTranslator.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/VersionedODataTemplateTranslator.cs @@ -39,7 +39,7 @@ public sealed class VersionedODataTemplateTranslator : IODataTemplateTranslator else { var model = context.Model; - var otherApiVersion = model.GetAnnotationValue( model )?.ApiVersion; + var otherApiVersion = model.GetApiVersion(); // HACK: a version-neutral endpoint can fail to match here because odata tries to match the // first endpoint metadata when there could be multiple. such an endpoint is expected to be diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/Routing/VersionedAttributeRoutingConvention.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/Routing/VersionedAttributeRoutingConvention.cs index 5dd11990..8ea4c5a2 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/Routing/VersionedAttributeRoutingConvention.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/Routing/VersionedAttributeRoutingConvention.cs @@ -55,7 +55,7 @@ public override bool AppliesToAction( ODataControllerActionContext context ) return false; } - var apiVersion = edm.GetAnnotationValue( edm )?.ApiVersion; + var apiVersion = edm.GetApiVersion(); if ( apiVersion == null || !metadata.IsMappedTo( apiVersion ) ) { diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.Tests/OData/VersionedODataModelBuilderTest.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.Tests/OData/VersionedODataModelBuilderTest.cs index 29be43a2..b40917a9 100644 --- a/src/AspNetCore/OData/test/Asp.Versioning.OData.Tests/OData/VersionedODataModelBuilderTest.cs +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.Tests/OData/VersionedODataModelBuilderTest.cs @@ -29,7 +29,7 @@ public void get_edm_models_should_return_expected_results() var model = builder.GetEdmModels().Single(); // assert - model.GetAnnotationValue( model ).ApiVersion.Should().Be( apiVersion ); + model.GetApiVersion().Should().Be( apiVersion ); modelCreated.Verify( f => f( It.IsAny(), model ), Once() ); } diff --git a/src/Common/src/Common.OData.ApiExplorer/OData/DefaultModelTypeBuilder.cs b/src/Common/src/Common.OData.ApiExplorer/OData/DefaultModelTypeBuilder.cs index 461d6cf0..62df945a 100644 --- a/src/Common/src/Common.OData.ApiExplorer/OData/DefaultModelTypeBuilder.cs +++ b/src/Common/src/Common.OData.ApiExplorer/OData/DefaultModelTypeBuilder.cs @@ -25,10 +25,19 @@ namespace Asp.Versioning.OData; public sealed class DefaultModelTypeBuilder : IModelTypeBuilder { private static Type? ienumerableOfT; + private readonly bool adHoc; + private DefaultModelTypeBuilder? adHocBuilder; private ConcurrentDictionary? modules; private ConcurrentDictionary>? generatedEdmTypesPerVersion; private ConcurrentDictionary>? generatedActionParamsPerVersion; + private DefaultModelTypeBuilder( bool adHoc ) => this.adHoc = adHoc; + + /// + /// Initializes a new instance of the class. + /// + public DefaultModelTypeBuilder() { } + /// public Type NewStructuredType( IEdmModel model, IEdmStructuredType structuredType, Type clrType, ApiVersion apiVersion ) { @@ -37,6 +46,12 @@ public Type NewStructuredType( IEdmModel model, IEdmStructuredType structuredTyp throw new ArgumentNullException( nameof( model ) ); } + if ( !adHoc && model.IsAdHoc() ) + { + adHocBuilder ??= new( adHoc: true ); + return adHocBuilder.NewStructuredType( model, structuredType, clrType, apiVersion ); + } + if ( structuredType == null ) { throw new ArgumentNullException( nameof( structuredType ) ); @@ -54,10 +69,9 @@ public Type NewStructuredType( IEdmModel model, IEdmStructuredType structuredTyp generatedEdmTypesPerVersion ??= new(); - var typeKey = new EdmTypeKey( structuredType, apiVersion ); var edmTypes = generatedEdmTypesPerVersion.GetOrAdd( apiVersion, key => GenerateTypesForEdmModel( model, key ) ); - return edmTypes[typeKey]; + return edmTypes[new( structuredType, apiVersion )]; } /// @@ -68,6 +82,12 @@ public Type NewActionParameters( IEdmModel model, IEdmAction action, string cont throw new ArgumentNullException( nameof( model ) ); } + if ( !adHoc && model.IsAdHoc() ) + { + adHocBuilder ??= new( adHoc: true ); + return adHocBuilder.NewActionParameters( model, action, controllerName, apiVersion ); + } + if ( action == null ) { throw new ArgumentNullException( nameof( action ) ); @@ -85,7 +105,7 @@ public Type NewActionParameters( IEdmModel model, IEdmAction action, string cont generatedActionParamsPerVersion ??= new(); - var paramTypes = generatedActionParamsPerVersion.GetOrAdd( apiVersion, _ => new ConcurrentDictionary() ); + var paramTypes = generatedActionParamsPerVersion.GetOrAdd( apiVersion, _ => new() ); var fullTypeName = $"{controllerName}.{action.Namespace}.{controllerName}{action.Name}Parameters"; var key = new EdmTypeKey( fullTypeName, apiVersion ); var type = paramTypes.GetOrAdd( key, _ => @@ -322,8 +342,7 @@ private static Type ResolveType( } [MethodImpl( MethodImplOptions.AggressiveInlining )] - private static Type MakeEnumerable( Type itemType ) => - ( ienumerableOfT ??= typeof( IEnumerable<> ) ).MakeGenericType( itemType ); + private static Type MakeEnumerable( Type itemType ) => ( ienumerableOfT ??= typeof( IEnumerable<> ) ).MakeGenericType( itemType ); [MethodImpl( MethodImplOptions.AggressiveInlining )] private static Type CreateTypeFromSignature( ModuleBuilder moduleBuilder, ClassSignature @class ) => @@ -389,7 +408,11 @@ private static IDictionary ResolveDependencies( BuilderContext return edmTypes; } - private static PropertyBuilder AddProperty( TypeBuilder addTo, Type shouldBeAdded, string name, IReadOnlyList customAttributes ) + private static PropertyBuilder AddProperty( + TypeBuilder addTo, + Type shouldBeAdded, + string name, + IReadOnlyList customAttributes ) { const MethodAttributes propertyMethodAttributes = MethodAttributes.Public | MethodAttributes.SpecialName | MethodAttributes.HideBySig; var field = addTo.DefineField( "_" + name, shouldBeAdded, FieldAttributes.Private ); @@ -418,7 +441,7 @@ private static PropertyBuilder AddProperty( TypeBuilder addTo, Type shouldBeAdde return propertyBuilder; } - private static AssemblyName NewAssemblyName(ApiVersion apiVersion) + private static AssemblyName NewAssemblyName( ApiVersion apiVersion, bool adHoc ) { // this is not strictly necessary, but it makes debugging a bit easier as each // assembly-qualified type name provides visibility as to which api version a @@ -455,20 +478,26 @@ private static AssemblyName NewAssemblyName(ApiVersion apiVersion) } name.Insert( 0, 'V' ) - .Append( NewGuid().ToString( "n", InvariantCulture ) ) - .Append( ".DynamicModels" ); + .Append( NewGuid().ToString( "n", InvariantCulture ) ); + + if ( adHoc ) + { + name.Append( ".AdHoc" ); + } + + name.Append( ".DynamicModels" ); return new( name.ToString() ); } [MethodImpl( MethodImplOptions.AggressiveInlining )] - private static ModuleBuilder CreateModuleForApiVersion( ApiVersion apiVersion ) + private ModuleBuilder CreateModuleForApiVersion( ApiVersion apiVersion ) { - var name = NewAssemblyName( apiVersion ); + var assemblyName = NewAssemblyName( apiVersion, adHoc ); #if NETFRAMEWORK - var assemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly( name, Run ); + var assemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly( assemblyName, Run ); #else - var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly( name, Run ); + var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly( assemblyName, Run ); #endif return assemblyBuilder.DefineDynamicModule( "" ); } diff --git a/src/Common/src/Common.OData.ApiExplorer/OData/TypeExtensions.cs b/src/Common/src/Common.OData.ApiExplorer/OData/TypeExtensions.cs index fb13bbe8..bca4a961 100644 --- a/src/Common/src/Common.OData.ApiExplorer/OData/TypeExtensions.cs +++ b/src/Common/src/Common.OData.ApiExplorer/OData/TypeExtensions.cs @@ -3,11 +3,13 @@ namespace Asp.Versioning.OData; #if NETFRAMEWORK +using Microsoft.OData.Edm; using System.Net.Http; using System.Web.Http; #else using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OData.Results; +using Microsoft.OData.Edm; #endif using System.Reflection; using System.Reflection.Emit; @@ -54,12 +56,11 @@ public static Type SubstituteIfNecessary( this Type type, TypeSubstitutionContex var openTypes = new Stack(); var apiVersion = context.ApiVersion; var resolver = new StructuredTypeResolver( context.Model ); + IEdmStructuredType? structuredType; if ( IsSubstitutableGeneric( type, openTypes, out var innerType ) ) { - var structuredType = resolver.GetStructuredType( innerType! ); - - if ( structuredType == null ) + if ( ( structuredType = resolver.GetStructuredType( innerType! ) ) == null ) { return type; } @@ -74,14 +75,9 @@ public static Type SubstituteIfNecessary( this Type type, TypeSubstitutionContex return CloseGeneric( openTypes, newType ); } - if ( CanBeSubstituted( type ) ) + if ( CanBeSubstituted( type ) && ( structuredType = resolver.GetStructuredType( type ) ) != null ) { - var structuredType = resolver.GetStructuredType( type ); - - if ( structuredType != null ) - { - type = context.ModelTypeBuilder.NewStructuredType( context.Model, structuredType, type, apiVersion ); - } + type = context.ModelTypeBuilder.NewStructuredType( context.Model, structuredType, type, apiVersion ); } return type; @@ -242,16 +238,15 @@ private static Type CloseGeneric( Stack openTypes, Type innerType ) return type; } - private static bool CanBeSubstituted( Type type ) - { - return Type.GetTypeCode( type ) == TypeCode.Object && - !type.IsValueType && - !type.Equals( ActionResultType ) && + [MethodImpl( MethodImplOptions.AggressiveInlining )] + private static bool CanBeSubstituted( Type type ) => + Type.GetTypeCode( type ) == TypeCode.Object && + !type.IsValueType && + !type.Equals( ActionResultType ) && #if NETFRAMEWORK - !type.Equals( HttpResponseType ) && + !type.Equals( HttpResponseType ) && #endif - !type.IsODataActionParameters(); - } + !type.IsODataActionParameters(); internal static bool IsEnumerable( this Type type, @@ -295,6 +290,7 @@ internal static bool IsEnumerable( return false; } + [MethodImpl( MethodImplOptions.AggressiveInlining )] private static bool IsSingleResult( this Type type ) => type.Is( SingleResultOfT ); private static bool IsODataValue( this Type? type ) @@ -323,6 +319,7 @@ private static bool IsODataValue( this Type? type ) private static bool Is( this Type type, Type typeDefinition ) => type.IsGenericType && type.GetGenericTypeDefinition().Equals( typeDefinition ); + [MethodImpl( MethodImplOptions.AggressiveInlining )] private static bool ShouldExtractInnerType( this Type type ) => type.IsDelta() || #if !NETFRAMEWORK diff --git a/src/Common/src/Common.OData.ApiExplorer/OData/TypeSubstitutionContext.cs b/src/Common/src/Common.OData.ApiExplorer/OData/TypeSubstitutionContext.cs index baad2af6..b47ae724 100644 --- a/src/Common/src/Common.OData.ApiExplorer/OData/TypeSubstitutionContext.cs +++ b/src/Common/src/Common.OData.ApiExplorer/OData/TypeSubstitutionContext.cs @@ -36,7 +36,7 @@ public class TypeSubstitutionContext /// Gets API version associated with the source model. /// /// The associated API version. - public ApiVersion ApiVersion => apiVersion ??= Model.GetAnnotationValue( Model )?.ApiVersion ?? ApiVersion.Neutral; + public ApiVersion ApiVersion => apiVersion ??= Model.GetApiVersion() ?? ApiVersion.Neutral; /// /// Gets the model type builder used to create substitution types. diff --git a/src/Common/src/Common.OData/Microsoft.OData.Edm/IEdmModelExtensions.cs b/src/Common/src/Common.OData/Microsoft.OData.Edm/IEdmModelExtensions.cs new file mode 100644 index 00000000..04d14dd6 --- /dev/null +++ b/src/Common/src/Common.OData/Microsoft.OData.Edm/IEdmModelExtensions.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Microsoft.OData.Edm; + +using Asp.Versioning; +using Asp.Versioning.OData; + +/// +/// Provides extension methods for . +/// +public static class IEdmModelExtensions +{ + /// + /// Gets the API version associated with the Entity Data Model (EDM). + /// + /// The extended EDM. + /// The associated API version or null. + public static ApiVersion? GetApiVersion( this IEdmModel model ) => + model.GetAnnotationValue( model )?.ApiVersion; + + /// + /// Gets a value indicating whether the Entity Data Model (EDM) is for defined ad hoc usage. + /// + /// The extended EDM. + /// True if the EDM is defined for ad hoc usage; otherwise, false. + public static bool IsAdHoc( this IEdmModel model ) => + model.GetAnnotationValue( model ) is not null; +} \ No newline at end of file diff --git a/src/Common/src/Common.OData/OData/AdHocAnnotation.cs b/src/Common/src/Common.OData/OData/AdHocAnnotation.cs new file mode 100644 index 00000000..c787bacc --- /dev/null +++ b/src/Common/src/Common.OData/OData/AdHocAnnotation.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.OData; + +/// +/// Represents an annotation for ad hoc usage. +/// +public sealed class AdHocAnnotation +{ + /// + /// Gets a singleton instance of the annotation. + /// + /// A singleton annotation instance. + public static AdHocAnnotation Instance { get; } = new(); +} \ No newline at end of file From 4df1cc35f66270a5aa481dab4e63199e259b8e0a Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Tue, 6 Dec 2022 10:52:30 -0800 Subject: [PATCH 4/8] Define option to opt out of ad hoc model type substitution --- .../OData/DefaultModelTypeBuilder.cs | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/Common/src/Common.OData.ApiExplorer/OData/DefaultModelTypeBuilder.cs b/src/Common/src/Common.OData.ApiExplorer/OData/DefaultModelTypeBuilder.cs index 62df945a..37c3bebc 100644 --- a/src/Common/src/Common.OData.ApiExplorer/OData/DefaultModelTypeBuilder.cs +++ b/src/Common/src/Common.OData.ApiExplorer/OData/DefaultModelTypeBuilder.cs @@ -24,6 +24,18 @@ namespace Asp.Versioning.OData; /// public sealed class DefaultModelTypeBuilder : IModelTypeBuilder { + /* design: there is typically a 1:1 relationship between an edm and api version. odata model bound settings + * are realized as an annotation in the edm. this can result in two sets of pairs where one edm is the + * standard mapping and the other is ad hoc for the purposes of query option settings. aside for the bucket + * they land in, there is no difference in how types will be mapped; however if the wrong edm from the + * incorrect bucket is picked, then the type mapping will fail. the model type builder detects if a model + * is ad hoc. if it is, then it will recursively create a private instance of itself to handle the ad hoc + * bucket. normal odata cannot opt out of this process because the explored type must match the edm. a type + * mapped via an ad hoc edm is not really odata so it can opt out if desired. the opt out process is more + * of a failsafe and optimization. if the ad hoc edm wasn't customized, then the meta model and type should + * be exactly the same, which will result in no substitution. + */ + private static Type? ienumerableOfT; private readonly bool adHoc; private DefaultModelTypeBuilder? adHocBuilder; @@ -38,6 +50,14 @@ public sealed class DefaultModelTypeBuilder : IModelTypeBuilder /// public DefaultModelTypeBuilder() { } + /// + /// Gets or sets a value indicating whether types from an ad hoc Entity Data Model + /// (EDM) should be excluded. + /// + /// True if types from an ad hoc EDM are excluded; otherwise, false. The + /// default value is false. + public bool ExcludeAdHocModels { get; set; } + /// public Type NewStructuredType( IEdmModel model, IEdmStructuredType structuredType, Type clrType, ApiVersion apiVersion ) { @@ -46,10 +66,17 @@ public Type NewStructuredType( IEdmModel model, IEdmStructuredType structuredTyp throw new ArgumentNullException( nameof( model ) ); } - if ( !adHoc && model.IsAdHoc() ) + if ( model.IsAdHoc() ) { - adHocBuilder ??= new( adHoc: true ); - return adHocBuilder.NewStructuredType( model, structuredType, clrType, apiVersion ); + if ( ExcludeAdHocModels ) + { + return clrType; + } + else if ( !adHoc ) + { + adHocBuilder ??= new( adHoc: true ); + return adHocBuilder.NewStructuredType( model, structuredType, clrType, apiVersion ); + } } if ( structuredType == null ) From 244450dc07b2b648cea1436b3ef5627d07e39994 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Tue, 6 Dec 2022 10:53:36 -0800 Subject: [PATCH 5/8] Refactor to use OptionsFactory base implementation --- .../ApiExplorerOptionsFactory{T}.cs | 71 +++++++------------ 1 file changed, 26 insertions(+), 45 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiExplorerOptionsFactory{T}.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiExplorerOptionsFactory{T}.cs index 810f3a25..b2d866c9 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiExplorerOptionsFactory{T}.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiExplorerOptionsFactory{T}.cs @@ -3,14 +3,13 @@ namespace Asp.Versioning.ApiExplorer; using Microsoft.Extensions.Options; -using Option = Microsoft.Extensions.Options.Options; /// /// Represents a factory to create API explorer options. /// /// The type of options to create. [CLSCompliant( false )] -public class ApiExplorerOptionsFactory : IOptionsFactory where T : ApiExplorerOptions, new() +public class ApiExplorerOptionsFactory : OptionsFactory where T : ApiExplorerOptions { private readonly IOptions optionsHolder; @@ -27,60 +26,42 @@ public ApiExplorerOptionsFactory( IOptions options, IEnumerable> setups, IEnumerable> postConfigures ) - { - optionsHolder = options; - Setups = setups; - PostConfigures = postConfigures; - } + : base( setups, postConfigures ) => optionsHolder = options; /// - /// Gets the API versioning options associated with the factory. - /// - /// The API versioning options used to create API explorer options. - protected ApiVersioningOptions Options => optionsHolder.Value; - - /// - /// Gets the associated configuration actions to run. + /// Initializes a new instance of the class. /// - /// The sequence of - /// configuration actions to run. - protected IEnumerable> Setups { get; } + /// The API versioning options + /// used to create API explorer options. + /// The sequence of + /// configuration actions to run. + /// The sequence of + /// initialization actions to run. + /// The sequence of + /// validations to run. + public ApiExplorerOptionsFactory( + IOptions options, + IEnumerable> setups, + IEnumerable> postConfigures, + IEnumerable> validations ) + : base( setups, postConfigures, validations ) => optionsHolder = options; /// - /// Gets the associated initialization actions to run. + /// Gets the API versioning options associated with the factory. /// - /// The sequence of - /// initialization actions to run. - protected IEnumerable> PostConfigures { get; } + /// The API versioning options used to create API explorer options. + protected ApiVersioningOptions Options => optionsHolder.Value; /// - public virtual T Create( string name ) + protected override T CreateInstance( string name ) { var apiVersioningOptions = Options; - var options = new T() - { - AssumeDefaultVersionWhenUnspecified = apiVersioningOptions.AssumeDefaultVersionWhenUnspecified, - ApiVersionParameterSource = apiVersioningOptions.ApiVersionReader, - DefaultApiVersion = apiVersioningOptions.DefaultApiVersion, - RouteConstraintName = apiVersioningOptions.RouteConstraintName, - }; - - foreach ( var setup in Setups ) - { - if ( setup is IConfigureNamedOptions namedSetup ) - { - namedSetup.Configure( name, options ); - } - else if ( name == Option.DefaultName ) - { - setup.Configure( options ); - } - } + var options = base.CreateInstance( name ); - foreach ( var post in PostConfigures ) - { - post.PostConfigure( name, options ); - } + options.AssumeDefaultVersionWhenUnspecified = apiVersioningOptions.AssumeDefaultVersionWhenUnspecified; + options.ApiVersionParameterSource = apiVersioningOptions.ApiVersionReader; + options.DefaultApiVersion = apiVersioningOptions.DefaultApiVersion; + options.RouteConstraintName = apiVersioningOptions.RouteConstraintName; return options; } From 88428551e429b1397c8f2b1978e2b5fe374e6f50 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Tue, 6 Dec 2022 10:55:45 -0800 Subject: [PATCH 6/8] Add support for exploring OData query options using an ad hoc EDM. Related #928 --- .../ApiExplorer/AdHocEdmScope.cs | 100 ++++++++ .../ApiExplorer/ODataApiExplorer.cs | 58 ++++- .../ApiExplorer/ODataApiExplorerOptions.cs | 3 +- .../ImplicitModelBoundSettingsConvention.cs | 31 +++ .../ODataQueryOptionsConventionBuilder.cs | 17 -- .../Description/ApiDescriptionExtensions.cs | 154 ++++++------ .../Description/ODataApiExplorerTest.cs | 34 +++ .../Simulators/Models/Book.cs | 3 + .../Simulators/V1/BooksController.cs | 3 + .../ApiExplorer/ApiDescriptionExtensions.cs | 21 ++ .../ApiExplorer/ODataApiExplorerOptions.cs | 19 ++ .../ODataApiExplorerOptionsFactory.cs | 112 +++++++++ .../PartialODataDescriptionProvider.cs | 220 ++++++++++++++++++ .../ImplicitModelBoundSettingsConvention.cs | 49 ++++ .../IApiVersioningBuilderExtensions.cs | 9 +- .../ODataApiDescriptionProviderTest.cs | 27 +++ .../Simulators/Models/Book.cs | 3 + .../ApiExplorer/ODataApiExplorerOptions.cs | 10 + .../ImplicitModelBoundSettingsConvention.cs | 84 +++++++ .../ODataQueryOptionsConventionBuilder.cs | 2 +- 20 files changed, 857 insertions(+), 102 deletions(-) create mode 100644 src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/AdHocEdmScope.cs create mode 100644 src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs create mode 100644 src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ApiDescriptionExtensions.cs create mode 100644 src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs create mode 100644 src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptionsFactory.cs create mode 100644 src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/PartialODataDescriptionProvider.cs create mode 100644 src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs create mode 100644 src/Common/src/Common.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/AdHocEdmScope.cs b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/AdHocEdmScope.cs new file mode 100644 index 00000000..2bcf1549 --- /dev/null +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/AdHocEdmScope.cs @@ -0,0 +1,100 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +using Asp.Versioning.Conventions; +using Asp.Versioning.Description; +using Asp.Versioning.OData; +using Microsoft.OData.Edm; +using System.Collections.Generic; +using System.Web.Http; +using System.Web.Http.Description; + +internal sealed class AdHocEdmScope : IDisposable +{ + private readonly IReadOnlyList results; + private bool disposed; + + internal AdHocEdmScope( + IReadOnlyList apiDescriptions, + VersionedODataModelBuilder builder ) + { + var conventions = builder.ModelConfigurations.OfType().ToArray(); + + results = FilterResults( apiDescriptions, conventions ); + + if ( results.Count > 0 ) + { + ApplyAdHocEdm( builder.GetEdmModels(), results ); + } + } + + public void Dispose() + { + if ( disposed ) + { + return; + } + + disposed = true; + + for ( var i = 0; i < results.Count; i++ ) + { + results[i].SetProperty( default( IEdmModel ) ); + } + } + + private static IReadOnlyList FilterResults( + IReadOnlyList apiDescriptions, + IReadOnlyList conventions ) + { + if ( conventions.Count == 0 ) + { + return Array.Empty(); + } + + var results = default( List ); + + for ( var i = 0; i < apiDescriptions.Count; i++ ) + { + var apiDescription = apiDescriptions[i]; + + if ( apiDescription.EdmModel() != null || !apiDescription.IsODataLike() ) + { + continue; + } + + results ??= new(); + results.Add( apiDescription ); + + for ( var j = 0; j < conventions.Count; j++ ) + { + conventions[j].ApplyTo( apiDescription ); + } + } + + return results?.ToArray() ?? Array.Empty(); + } + + private static void ApplyAdHocEdm( + IReadOnlyList models, + IReadOnlyList results ) + { + for ( var i = 0; i < models.Count; i++ ) + { + var model = models[i]; + var version = model.GetApiVersion(); + + for ( var j = 0; j < results.Count; j++ ) + { + var result = results[j]; + var metadata = result.ActionDescriptor.GetApiVersionMetadata(); + + if ( metadata.IsMappedTo( version ) ) + { + result.SetProperty( model ); + } + } + } + } +} \ No newline at end of file diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorer.cs b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorer.cs index f6fda7f9..4c45eb70 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorer.cs +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorer.cs @@ -7,6 +7,7 @@ namespace Asp.Versioning.ApiExplorer; using Asp.Versioning.OData; using Asp.Versioning.Routing; using Microsoft.AspNet.OData; +using Microsoft.AspNet.OData.Builder; using Microsoft.AspNet.OData.Extensions; using Microsoft.AspNet.OData.Formatter; using Microsoft.AspNet.OData.Routing; @@ -16,6 +17,7 @@ namespace Asp.Versioning.ApiExplorer; using Microsoft.OData.UriParser; using System.Collections.ObjectModel; using System.Net.Http.Formatting; +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using System.Web.Http; using System.Web.Http.Controllers; @@ -50,7 +52,11 @@ public ODataApiExplorer( HttpConfiguration configuration ) /// The current HTTP configuration. /// The associated API explorer options. public ODataApiExplorer( HttpConfiguration configuration, ODataApiExplorerOptions options ) - : base( configuration, options ) => this.options = options; + : base( configuration, options ) + { + this.options = options; + options.AdHocModelBuilder.OnModelCreated += MarkAsAdHoc; + } /// /// Gets the options associated with the API explorer. @@ -172,7 +178,20 @@ protected override Collection ExploreRouteControllers( if ( route is not ODataRoute ) { apiDescriptions = base.ExploreRouteControllers( controllerMappings, route, apiVersion ); - return ExploreQueryOptions( route, apiDescriptions ); + + if ( Options.AdHocModelBuilder.ModelConfigurations.Count == 0 ) + { + ExploreQueryOptions( route, apiDescriptions ); + } + else if ( apiDescriptions.Count > 0 ) + { + using ( new AdHocEdmScope( apiDescriptions, Options.AdHocModelBuilder ) ) + { + ExploreQueryOptions( route, apiDescriptions ); + } + } + + return apiDescriptions; } apiDescriptions = new(); @@ -199,7 +218,8 @@ protected override Collection ExploreRouteControllers( } } - return ExploreQueryOptions( route, apiDescriptions ); + ExploreQueryOptions( route, apiDescriptions ); + return apiDescriptions; } /// @@ -210,7 +230,25 @@ protected override Collection ExploreDirectRouteControl ApiVersion apiVersion ) { var apiDescriptions = base.ExploreDirectRouteControllers( controllerDescriptor, candidateActionDescriptors, route, apiVersion ); - return ExploreQueryOptions( route, apiDescriptions ); + + if ( apiDescriptions.Count == 0 ) + { + return apiDescriptions; + } + + if ( Options.AdHocModelBuilder.ModelConfigurations.Count == 0 ) + { + ExploreQueryOptions( route, apiDescriptions ); + } + else if ( apiDescriptions.Count > 0 ) + { + using ( new AdHocEdmScope( apiDescriptions, Options.AdHocModelBuilder ) ) + { + ExploreQueryOptions( route, apiDescriptions ); + } + } + + return apiDescriptions; } /// @@ -238,20 +276,20 @@ protected virtual void ExploreQueryOptions( queryOptions.ApplyTo( apiDescriptions, settings ); } - private Collection ExploreQueryOptions( - IHttpRoute route, - Collection apiDescriptions ) + [MethodImpl( MethodImplOptions.AggressiveInlining )] + private static void MarkAsAdHoc( ODataModelBuilder builder, IEdmModel model ) => + model.SetAnnotationValue( model, AdHocAnnotation.Instance ); + + private void ExploreQueryOptions( IHttpRoute route, Collection apiDescriptions ) { if ( apiDescriptions.Count == 0 ) { - return apiDescriptions; + return; } var uriResolver = Configuration.GetODataRootContainer( route ).GetRequiredService(); ExploreQueryOptions( apiDescriptions, uriResolver ); - - return apiDescriptions; } private ResponseDescription CreateResponseDescriptionWithRoute( diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs index b9d34d6b..4bf5b113 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs @@ -15,7 +15,8 @@ public partial class ODataApiExplorerOptions : ApiExplorerOptions /// Initializes a new instance of the class. /// /// The current configuration associated with the options. - public ODataApiExplorerOptions( HttpConfiguration configuration ) : base( configuration ) { } + public ODataApiExplorerOptions( HttpConfiguration configuration ) + : base( configuration ) => AdHocModelBuilder = new( configuration ); /// /// Gets or sets a value indicating whether the API explorer settings are honored. diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs new file mode 100644 index 00000000..771af4de --- /dev/null +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Conventions; + +using Asp.Versioning.OData; +using System.Web.Http.Description; + +/// +/// Provides additional implementation specific to ASP.NET Web API. +/// +public partial class ImplicitModelBoundSettingsConvention : IModelConfiguration, IODataQueryOptionsConvention +{ + /// + public void ApplyTo( ApiDescription apiDescription ) + { + var response = apiDescription.ResponseDescription; + var type = response.ResponseType ?? response.DeclaredType; + + if ( type == null ) + { + return; + } + + if ( type.IsEnumerable( out var itemType ) ) + { + type = itemType; + } + + types.Add( type! ); + } +} \ No newline at end of file diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs index 04f8cf6d..8b297ebb 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs @@ -2,7 +2,6 @@ namespace Asp.Versioning.Conventions; -using Microsoft.AspNet.OData; using System.Runtime.CompilerServices; using System.Web.Http.Description; @@ -14,20 +13,4 @@ public partial class ODataQueryOptionsConventionBuilder [MethodImpl( MethodImplOptions.AggressiveInlining )] private static Type GetController( ApiDescription apiDescription ) => apiDescription.ActionDescriptor.ControllerDescriptor.ControllerType; - - [MethodImpl( MethodImplOptions.AggressiveInlining )] - private static bool IsODataLike( ApiDescription description ) - { - var parameters = description.ParameterDescriptions; - - for ( var i = 0; i < parameters.Count; i++ ) - { - if ( parameters[i].ParameterDescriptor.ParameterType.IsODataQueryOptions() ) - { - return true; - } - } - - return false; - } } \ No newline at end of file diff --git a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/System.Web.Http/Description/ApiDescriptionExtensions.cs b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/System.Web.Http/Description/ApiDescriptionExtensions.cs index 3c19916d..cba8d13e 100644 --- a/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/System.Web.Http/Description/ApiDescriptionExtensions.cs +++ b/src/AspNet/OData/src/Asp.Versioning.WebApi.OData.ApiExplorer/System.Web.Http/Description/ApiDescriptionExtensions.cs @@ -1,100 +1,114 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. -namespace System.Web.Http.Description -{ - using Asp.Versioning.Description; - using Microsoft.AspNet.OData.Routing; - using Microsoft.OData.Edm; +namespace System.Web.Http.Description; + +using Asp.Versioning.Description; +using Microsoft.AspNet.OData.Routing; +using Microsoft.OData.Edm; +/// +/// Provides extension methods for the class. +/// +public static class ApiDescriptionExtensions +{ /// - /// Provides extension methods for the class. + /// Gets the entity data model (EDM) associated with the API description. /// - public static class ApiDescriptionExtensions + /// The API description to get the model for. + /// The associated EDM model or null if there is no associated model. + public static IEdmModel? EdmModel( this ApiDescription apiDescription ) { - /// - /// Gets the entity data model (EDM) associated with the API description. - /// - /// The API description to get the model for. - /// The associated EDM model or null if there is no associated model. - public static IEdmModel? EdmModel( this ApiDescription apiDescription ) + if ( apiDescription is VersionedApiDescription description ) { - if ( apiDescription is VersionedApiDescription description ) - { - return description.GetProperty(); - } + return description.GetProperty(); + } + return default; + } + + /// + /// Gets the entity set associated with the API description. + /// + /// The API description to get the entity set for. + /// The associated entity set or null if there is no associated entity set. + public static IEdmEntitySet? EntitySet( this ApiDescription apiDescription ) + { + if ( apiDescription is not VersionedApiDescription description ) + { return default; } - /// - /// Gets the entity set associated with the API description. - /// - /// The API description to get the entity set for. - /// The associated entity set or null if there is no associated entity set. - public static IEdmEntitySet? EntitySet( this ApiDescription apiDescription ) + var key = typeof( IEdmEntitySet ); + + if ( description.Properties.TryGetValue( key, out var value ) ) { - if ( apiDescription is not VersionedApiDescription description ) - { - return default; - } + return (IEdmEntitySet) value; + } - var key = typeof( IEdmEntitySet ); + var container = description.EdmModel()?.EntityContainer; - if ( description.Properties.TryGetValue( key, out var value ) ) - { - return (IEdmEntitySet) value; - } + if ( container == null ) + { + return default; + } - var container = description.EdmModel()?.EntityContainer; + var entitySetName = description.ActionDescriptor.ControllerDescriptor.ControllerName; + var entitySet = container.FindEntitySet( entitySetName ); - if ( container == null ) - { - return default; - } + description.Properties[key] = entitySet; - var entitySetName = description.ActionDescriptor.ControllerDescriptor.ControllerName; - var entitySet = container.FindEntitySet( entitySetName ); + return entitySet; + } - description.Properties[key] = entitySet; + /// + /// Gets the entity type associated with the API description. + /// + /// The API description to get the entity type for. + /// The associated entity type or null if there is no associated entity type. + public static IEdmEntityType? EntityType( this ApiDescription apiDescription ) => apiDescription.EntitySet()?.EntityType(); - return entitySet; + /// + /// Gets the operation associated with the API description. + /// + /// The API description to get the operation for. + /// The associated EDM operation or null if there is no associated operation. + public static IEdmOperation? Operation( this ApiDescription apiDescription ) + { + if ( apiDescription is VersionedApiDescription description ) + { + return description.GetProperty(); } - /// - /// Gets the entity type associated with the API description. - /// - /// The API description to get the entity type for. - /// The associated entity type or null if there is no associated entity type. - public static IEdmEntityType? EntityType( this ApiDescription apiDescription ) => apiDescription.EntitySet()?.EntityType(); - - /// - /// Gets the operation associated with the API description. - /// - /// The API description to get the operation for. - /// The associated EDM operation or null if there is no associated operation. - public static IEdmOperation? Operation( this ApiDescription apiDescription ) - { - if ( apiDescription is VersionedApiDescription description ) - { - return description.GetProperty(); - } + return default; + } - return default; + /// + /// Gets the route prefix associated with the API description. + /// + /// The API description to get the route prefix for. + /// The associated route prefix or null. + public static string? RoutePrefix( this ApiDescription apiDescription ) + { + if ( apiDescription == null ) + { + throw new ArgumentNullException( nameof( apiDescription ) ); } - /// - /// Gets the route prefix associated with the API description. - /// - /// The API description to get the route prefix for. - /// The associated route prefix or null. - public static string? RoutePrefix( this ApiDescription apiDescription ) + return apiDescription.Route is ODataRoute route ? route.RoutePrefix : default; + } + + internal static bool IsODataLike( this ApiDescription description ) + { + var parameters = description.ParameterDescriptions; + + for ( var i = 0; i < parameters.Count; i++ ) { - if ( apiDescription == null ) + if ( parameters[i].ParameterDescriptor.ParameterType.IsODataQueryOptions() ) { - throw new ArgumentNullException( nameof( apiDescription ) ); + return true; } - - return apiDescription.Route is ODataRoute route ? route.RoutePrefix : default; } + + return false; } } \ No newline at end of file diff --git a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Description/ODataApiExplorerTest.cs b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Description/ODataApiExplorerTest.cs index 534d10a3..34540a00 100644 --- a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Description/ODataApiExplorerTest.cs +++ b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Description/ODataApiExplorerTest.cs @@ -3,9 +3,13 @@ namespace Asp.Versioning.Description; using Asp.Versioning.ApiExplorer; +using Asp.Versioning.Controllers; +using Asp.Versioning.Conventions; using Microsoft.AspNet.OData; +using Microsoft.AspNet.OData.Extensions; using System.Net.Http; using System.Web.Http; +using System.Web.Http.Dispatcher; using static System.Net.Http.HttpMethod; public class ODataApiExplorerTest @@ -210,4 +214,34 @@ public void api_description_group_should_explore_navigation_properties() }, options => options.ExcludingMissingMembers() ); } + + [Fact] + public void api_description_group_should_explore_model_bound_settings() + { + // arrange + var configuration = new HttpConfiguration(); + var controllerTypeResolver = new ControllerTypeCollection( + typeof( VersionedMetadataController ), + typeof( Simulators.V1.BooksController ) ); + + configuration.Services.Replace( typeof( IHttpControllerTypeResolver ), controllerTypeResolver ); + configuration.EnableDependencyInjection(); + configuration.AddApiVersioning(); + configuration.MapHttpAttributeRoutes(); + + var apiVersion = new ApiVersion( 1.0 ); + var options = new ODataApiExplorerOptions( configuration ); + var apiExplorer = new ODataApiExplorer( configuration, options ); + + options.AdHocModelBuilder.ModelConfigurations.Add( new ImplicitModelBoundSettingsConvention() ); + + // act + var descriptionGroup = apiExplorer.ApiDescriptions[apiVersion]; + var description = descriptionGroup.ApiDescriptions[0]; + + // assert + var parameter = description.ParameterDescriptions.Single( p => p.Name == "$filter" ); + + parameter.Documentation.Should().EndWith( "author, published." ); + } } \ No newline at end of file diff --git a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Simulators/Models/Book.cs b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Simulators/Models/Book.cs index 404bf071..72b12f9b 100644 --- a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Simulators/Models/Book.cs +++ b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Simulators/Models/Book.cs @@ -2,6 +2,9 @@ namespace Asp.Versioning.Simulators.Models; +using Microsoft.AspNet.OData.Query; + +[Filter( "author", "published" )] public class Book { public string Id { get; set; } diff --git a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Simulators/V1/BooksController.cs b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Simulators/V1/BooksController.cs index e6e24820..24d7a24e 100644 --- a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Simulators/V1/BooksController.cs +++ b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.ApiExplorer.Tests/Simulators/V1/BooksController.cs @@ -14,6 +14,7 @@ namespace Asp.Versioning.Simulators.V1; /// Represents a RESTful service of books. /// [ApiVersion( 1.0 )] +[RoutePrefix( "api/books" )] public class BooksController : ApiController { private static readonly Book[] books = new Book[] @@ -33,6 +34,7 @@ public class BooksController : ApiController /// All available books. /// The successfully retrieved books. [HttpGet] + [Route] [ResponseType( typeof( IEnumerable ) )] public IHttpActionResult Get( ODataQueryOptions options ) => Ok( options.ApplyTo( books.AsQueryable() ) ); @@ -46,6 +48,7 @@ public IHttpActionResult Get( ODataQueryOptions options ) => /// The book was successfully retrieved. /// The book does not exist. [HttpGet] + [Route( "{id}" )] [ResponseType( typeof( Book ) )] public IHttpActionResult Get( string id, ODataQueryOptions options ) { diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ApiDescriptionExtensions.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ApiDescriptionExtensions.cs new file mode 100644 index 00000000..29520295 --- /dev/null +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ApiDescriptionExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer; + +internal static class ApiDescriptionExtensions +{ + internal static bool IsODataLike( this ApiDescription description ) + { + var parameters = description.ActionDescriptor.Parameters; + + for ( var i = 0; i < parameters.Count; i++ ) + { + if ( parameters[i].ParameterType.IsODataQueryOptions() ) + { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs new file mode 100644 index 00000000..5c73744b --- /dev/null +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +using Asp.Versioning.OData; + +/// +/// Provides additional implementation specific to ASP.NET Core. +/// +public partial class ODataApiExplorerOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The associated model builder. + [CLSCompliant( false )] + public ODataApiExplorerOptions( VersionedODataModelBuilder modelBuilder ) => + AdHocModelBuilder = modelBuilder ?? throw new ArgumentNullException( nameof( modelBuilder ) ); +} \ No newline at end of file diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptionsFactory.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptionsFactory.cs new file mode 100644 index 00000000..ae057645 --- /dev/null +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptionsFactory.cs @@ -0,0 +1,112 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +using Asp.Versioning.OData; +using Microsoft.Extensions.Options; +using static Asp.Versioning.ApiVersionMapping; + +/// +/// Represents a factory to create OData API explorer options. +/// +[CLSCompliant( false )] +public class ODataApiExplorerOptionsFactory : ApiExplorerOptionsFactory +{ + private readonly IApiVersionMetadataCollationProvider[] providers; + private readonly IEnumerable modelConfigurations; + + /// + /// Initializes a new instance of the class. + /// + /// A sequence of + /// providers used to collate API version metadata. + /// A sequence of + /// configurations used to configure Entity Data Models. + /// The API versioning options + /// used to create API explorer options. + /// The sequence of + /// configuration actions to run. + /// The sequence of + /// initialization actions to run. + public ODataApiExplorerOptionsFactory( + IEnumerable providers, + IEnumerable modelConfigurations, + IOptions options, + IEnumerable> setups, + IEnumerable> postConfigures ) + : base( options, setups, postConfigures ) + { + this.providers = ( providers ?? throw new ArgumentNullException( nameof( providers ) ) ).ToArray(); + this.modelConfigurations = modelConfigurations ?? throw new ArgumentNullException( nameof( modelConfigurations ) ); + } + + /// + /// Initializes a new instance of the class. + /// + /// A sequence of + /// providers used to collate API version metadata. + /// A sequence of + /// configurations used to configure Entity Data Models. + /// The API versioning options + /// used to create API explorer options. + /// The sequence of + /// configuration actions to run. + /// The sequence of + /// initialization actions to run. + /// The sequence of + /// validations to run. + public ODataApiExplorerOptionsFactory( + IEnumerable providers, + IEnumerable modelConfigurations, + IOptions options, + IEnumerable> setups, + IEnumerable> postConfigures, + IEnumerable> validations ) + : base( options, setups, postConfigures, validations ) + { + this.providers = ( providers ?? throw new ArgumentNullException( nameof( providers ) ) ).ToArray(); + this.modelConfigurations = modelConfigurations ?? throw new ArgumentNullException( nameof( modelConfigurations ) ); + } + + /// + protected override ODataApiExplorerOptions CreateInstance( string name ) => + new( new( CollateApiVersions( providers, Options ), modelConfigurations ) ); + + private static ODataApiVersionCollectionProvider CollateApiVersions( + IApiVersionMetadataCollationProvider[] providers, + ApiVersioningOptions options ) + { + var context = new ApiVersionMetadataCollationContext(); + + for ( var i = 0; i < providers.Length; i++ ) + { + providers[i].Execute( context ); + } + + var results = context.Results; + var versions = new SortedSet(); + + for ( var i = 0; i < results.Count; i++ ) + { + var model = results[i].Map( Implicit ); + var declared = model.DeclaredApiVersions; + + for ( var j = 0; j < declared.Count; j++ ) + { + versions.Add( declared[j] ); + } + } + + if ( versions.Count == 0 ) + { + versions.Add( options.DefaultApiVersion ); + } + + return new() { ApiVersions = versions.ToArray() }; + } + + private sealed class ODataApiVersionCollectionProvider : IODataApiVersionCollectionProvider + { + required public IReadOnlyList ApiVersions { get; set; } + } +} \ No newline at end of file diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/PartialODataDescriptionProvider.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/PartialODataDescriptionProvider.cs new file mode 100644 index 00000000..562a8469 --- /dev/null +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/PartialODataDescriptionProvider.cs @@ -0,0 +1,220 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +using Asp.Versioning; +using Asp.Versioning.Conventions; +using Asp.Versioning.OData; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.OData; +using Microsoft.AspNetCore.OData.Routing; +using Microsoft.AspNetCore.OData.Routing.Template; +using Microsoft.Extensions.Options; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; +using System.Runtime.CompilerServices; +using Opts = Microsoft.Extensions.Options.Options; + +/// +/// Reprensents an API description provider for partial OData support. +/// +[CLSCompliant( false )] +public class PartialODataDescriptionProvider : IApiDescriptionProvider +{ + private static int? beforeOData; + private readonly IOptionsFactory odataOptionsFactory; + private readonly IOptions options; + private bool markedAdHoc; + private IODataQueryOptionsConvention[]? conventions; + + /// + /// Initializes a new instance of the class. + /// + /// The factory used to create + /// OData options. + /// The container of configured + /// API explorer options. + public PartialODataDescriptionProvider( + IOptionsFactory odataOptionsFactory, + IOptions options ) + { + this.odataOptionsFactory = odataOptionsFactory ?? throw new ArgumentNullException( nameof( odataOptionsFactory ) ); + this.options = options ?? throw new ArgumentNullException( nameof( options ) ); + beforeOData ??= ODataOrder( odataOptionsFactory, options ) + 10; + Order = beforeOData.Value; + } + + /// + /// Gets the associated OData API explorer options. + /// + /// The current OData API explorer options. + protected ODataApiExplorerOptions Options + { + get + { + var value = options.Value; + + if ( !markedAdHoc ) + { + value.AdHocModelBuilder.OnModelCreated += MarkAsAdHoc; + markedAdHoc = true; + } + + return value; + } + } + + /// + /// Gets the builder used to create ad hoc Entity Data Models (EDMs). + /// + /// The associated model builder. + protected VersionedODataModelBuilder ModelBuilder => Options.AdHocModelBuilder; + + /// + /// Gets associated the OData query option conventions. + /// + /// A read-only list of + /// OData query option conventions. + protected IReadOnlyList Conventions => + conventions ??= Options.AdHocModelBuilder.ModelConfigurations.OfType().ToArray(); + + /// + /// Gets or sets the order precedence of the current API description provider. + /// + /// The order precedence of the current API description provider. + public int Order { get; protected set; } + + /// + public virtual void OnProvidersExecuting( ApiDescriptionProviderContext context ) + { + var results = FilterResults( context.Results, Conventions ); + + if ( results.Count == 0 ) + { + return; + } + + var models = ModelBuilder.GetEdmModels(); + + for ( var i = 0; i < models.Count; i++ ) + { + var model = models[i]; + var version = model.GetApiVersion(); + var options = odataOptionsFactory.Create( Opts.DefaultName ); + + options.AddRouteComponents( model ); + + for ( var j = 0; j < results.Count; j++ ) + { + var result = results[j]; + var metadata = result.ActionDescriptor.GetApiVersionMetadata(); + + if ( metadata.IsMappedTo( version ) ) + { + result.ActionDescriptor.EndpointMetadata.Add( ODataMetadata.New( model ) ); + } + } + } + } + + /// + public virtual void OnProvidersExecuted( ApiDescriptionProviderContext context ) + { + var actions = context.Actions; + + for ( var i = 0; i < actions.Count; i++ ) + { + var metadata = actions[i].EndpointMetadata; + + for ( var j = metadata.Count - 1; j >= 0; j-- ) + { + if ( metadata[j] is IODataRoutingMetadata routing && routing.Model.IsAdHoc() ) + { + metadata.Remove( j ); + } + } + } + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + private static int ODataOrder( IOptionsFactory factory, IOptions options ) => + new ODataApiDescriptionProvider( + new StubModelMetadataProvider(), + new StubModelTypeBuilder(), + factory, + options ).Order; + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + private static void MarkAsAdHoc( ODataModelBuilder builder, IEdmModel model ) => + model.SetAnnotationValue( model, AdHocAnnotation.Instance ); + + private static IReadOnlyList FilterResults( + IList results, + IReadOnlyList conventions ) + { + var filtered = default( List ); + + for ( var i = 0; i < results.Count; i++ ) + { + var result = results[i]; + var metadata = result.ActionDescriptor.EndpointMetadata; + var odata = false; + + for ( var j = 0; j < metadata.Count; j++ ) + { + if ( metadata[j] is IODataRoutingMetadata ) + { + odata = true; + break; + } + } + + if ( odata || !result.IsODataLike() ) + { + continue; + } + + filtered ??= new( capacity: results.Count ); + filtered.Add( result ); + + for ( var j = 0; j < conventions.Count; j++ ) + { + conventions[j].ApplyTo( result ); + } + } + + return filtered?.ToArray() ?? Array.Empty(); + } + + private sealed class StubModelMetadataProvider : IModelMetadataProvider + { + public IEnumerable GetMetadataForProperties( Type modelType ) => + throw new NotImplementedException(); + + public ModelMetadata GetMetadataForType( Type modelType ) => + throw new NotImplementedException(); + } + + private sealed class StubModelTypeBuilder : IModelTypeBuilder + { + public Type NewActionParameters( IEdmModel model, IEdmAction action, string controllerName, ApiVersion apiVersion ) => + throw new NotImplementedException(); + + public Type NewStructuredType( IEdmModel model, IEdmStructuredType structuredType, Type clrType, ApiVersion apiVersion ) => + throw new NotImplementedException(); + } + + private static class ODataMetadata + { + private const string ArbitrarySegment = "52459ff8-bca1-4a26-b7f2-08c7da04472d"; + + // metadata (~/$metadata) and service (~/) doc have special handling. + // make sure we don't match the service doc + private static readonly ODataPathTemplate AdHocODataTemplate = + new( new DynamicSegmentTemplate( new( ArbitrarySegment ) ) ); + + public static ODataRoutingMetadata New( IEdmModel model ) => new( string.Empty, model, AdHocODataTemplate ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs new file mode 100644 index 00000000..3800afd6 --- /dev/null +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs @@ -0,0 +1,49 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Conventions; + +using Asp.Versioning; +using Asp.Versioning.OData; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.OData.ModelBuilder; + +/// +/// Provides additional implementation specific to ASP.NET Core. +/// +[CLSCompliant( false )] +public partial class ImplicitModelBoundSettingsConvention +{ + /// + public void ApplyTo( ApiDescription apiDescription ) + { + if ( apiDescription == null ) + { + throw new ArgumentNullException( nameof( apiDescription ) ); + } + + var responses = apiDescription.SupportedResponseTypes; + + for ( var j = 0; j < responses.Count; j++ ) + { + var response = responses[j]; + var notForSuccess = response.StatusCode < 200 || response.StatusCode >= 300; + + if ( notForSuccess ) + { + continue; + } + + var model = response.ModelMetadata; + var type = model == null + ? response.Type + : model.IsEnumerableType + ? model.ElementType + : model.UnderlyingOrModelType; + + if ( type != null ) + { + types.Add( type ); + } + } + } +} \ No newline at end of file diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs index 27283ec7..d19c8ed5 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -4,6 +4,7 @@ namespace Microsoft.Extensions.DependencyInjection; using Asp.Versioning; using Asp.Versioning.ApiExplorer; +using Asp.Versioning.Conventions; using Asp.Versioning.OData; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -55,9 +56,12 @@ private static void AddApiExplorerServices( IApiVersioningBuilder builder ) var services = builder.Services; builder.AddApiExplorer(); + builder.Services.AddModelConfigurationsAsServices(); services.TryAddSingleton(); - services.TryAddSingleton, ApiExplorerOptionsFactory>(); + services.TryAddSingleton, ODataApiExplorerOptionsFactory>(); + services.TryAddEnumerable( Transient() ); services.TryAddEnumerable( Transient() ); + services.TryAddEnumerable( Transient() ); services.Replace( Singleton, ODataApiExplorerOptionsAdapter>() ); } @@ -67,8 +71,7 @@ private sealed class ODataApiExplorerOptionsAdapter : IOptionsFactory factory; - public ODataApiExplorerOptionsAdapter( IOptionsFactory factory ) => - this.factory = factory; + public ODataApiExplorerOptionsAdapter( IOptionsFactory factory ) => this.factory = factory; public ApiExplorerOptions Create( string name ) => factory.Create( name ); } 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 3c3f3136..511513b4 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 @@ -9,6 +9,7 @@ namespace Asp.Versioning.ApiExplorer; using Microsoft.AspNetCore.OData; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using System.Buffers; public class ODataApiDescriptionProviderTest { @@ -152,6 +153,8 @@ private void AssertVersion1( ApiDescriptionGroup group ) new { HttpMethod = "GET", GroupName, RelativePath = "api/People/{key}" }, }, options => options.ExcludingMissingMembers() ); + + AssertQueryOptionWithoutOData( items[0], "filter", "author", "published" ); } private void AssertVersion2( ApiDescriptionGroup group ) @@ -229,6 +232,30 @@ private void AssertVersion3( ApiDescriptionGroup group ) items.Should().BeEquivalentTo( expected, options => options.ExcludingMissingMembers() ); } + private static void AssertQueryOptionWithoutOData( ApiDescription description, string name, string property, params string[] otherProperties ) + { + var parameter = description.ParameterDescriptions.Single( p => p.Name == name ); + var count = otherProperties.Length + 1; + string suffix; + + if ( count == 1 ) + { + suffix = property; + } + else + { + var pool = ArrayPool.Shared; + var properties = pool.Rent( count ); + + properties[0] = property; + Array.Copy( otherProperties, 0, properties, 1, count - 1 ); + + suffix = string.Join( ", ", properties, 0, count ); + } + + parameter.ModelMetadata.Description.Should().EndWith( suffix + '.' ); + } + private void PrintGroup( IReadOnlyList items ) { for ( var i = 0; i < items.Count; i++ ) diff --git a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Models/Book.cs b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Models/Book.cs index 00fec7f1..97610da6 100644 --- a/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Models/Book.cs +++ b/src/AspNetCore/OData/test/Asp.Versioning.OData.ApiExplorer.Tests/Simulators/Models/Book.cs @@ -2,9 +2,12 @@ namespace Asp.Versioning.Simulators.Models; +using Microsoft.OData.ModelBuilder; + /// /// Represents a book. /// +[Filter( "author", "published" )] public class Book { /// diff --git a/src/Common/src/Common.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs b/src/Common/src/Common.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs index c90cafb5..45acaa44 100644 --- a/src/Common/src/Common.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs +++ b/src/Common/src/Common.OData.ApiExplorer/ApiExplorer/ODataApiExplorerOptions.cs @@ -3,6 +3,7 @@ namespace Asp.Versioning.ApiExplorer; using Asp.Versioning.Conventions; +using Asp.Versioning.OData; /// /// Represents the possible API versioning options for an OData API explorer. @@ -44,4 +45,13 @@ public ODataQueryOptionsConventionBuilder QueryOptions /// /// One or more values. public ODataMetadataOptions MetadataOptions { get; set; } = ODataMetadataOptions.None; + + /// + /// Gets the builder used to create ad hoc versioned Entity Data Models (EDMs). + /// + /// The associated model builder. +#if !NETFRAMEWORK + [CLSCompliant( false )] +#endif + public VersionedODataModelBuilder AdHocModelBuilder { get; } } \ No newline at end of file diff --git a/src/Common/src/Common.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs b/src/Common/src/Common.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs new file mode 100644 index 00000000..39dd9f3b --- /dev/null +++ b/src/Common/src/Common.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs @@ -0,0 +1,84 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Conventions; + +using Asp.Versioning; +using Asp.Versioning.OData; +#if NETFRAMEWORK +using Microsoft.AspNet.OData.Builder; +#else +using Microsoft.OData.ModelBuilder; +#endif + +/// +/// Represents an OData model bound settings model configuration +/// that is also an OData query options convention. +/// +public sealed partial class ImplicitModelBoundSettingsConvention : IModelConfiguration, IODataQueryOptionsConvention +{ + private readonly HashSet types = new(); + + /// + public void Apply( ODataModelBuilder builder, ApiVersion apiVersion, string? routePrefix ) + { + if ( builder == null ) + { + throw new ArgumentNullException( nameof( builder ) ); + } + + if ( types.Count == 0 ) + { + return; + } + + if ( GetExistingTypes( builder ) is HashSet existingTypes ) + { + types.ExceptWith( existingTypes ); + } + + if ( types.Count == 0 ) + { + return; + } + + // model configurations are applied unordered, which could matter. + // defer implicit registrations in the model until all other model + // configurations have been applied, if possible + if ( builder is ODataConventionModelBuilder modelBuilder ) + { + modelBuilder.OnModelCreating += OnModelCreating; + } + else + { + OnModelCreating( builder ); + } + } + + private static HashSet? GetExistingTypes( ODataModelBuilder builder ) + { + var types = default( HashSet ); + + foreach ( var entitySet in builder.EntitySets ) + { + types ??= new(); + types.Add( entitySet.ClrType ); + } + + foreach ( var singleton in builder.Singletons ) + { + types ??= new(); + types.Add( singleton.ClrType ); + } + + return types; + } + + private void OnModelCreating( ODataModelBuilder builder ) + { + foreach ( var type in types ) + { + var entityType = builder.AddEntityType( type ); + builder.AddEntitySet( entityType.Name, entityType ); + } + } +} \ No newline at end of file diff --git a/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs b/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs index bf2c4b6b..054f5641 100644 --- a/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs +++ b/src/Common/src/Common.OData.ApiExplorer/Conventions/ODataQueryOptionsConventionBuilder.cs @@ -135,7 +135,7 @@ public virtual void ApplyTo( IEnumerable apiDescriptions, ODataQ { var controller = GetController( description ); - if ( !controller.IsODataController() && !IsODataLike( description ) ) + if ( !controller.IsODataController() && !description.IsODataLike() ) { continue; } From 8ebb7d577ed23e49daee204dfbd1b62e09ab77b6 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Tue, 6 Dec 2022 10:56:19 -0800 Subject: [PATCH 7/8] Add model bound settings to partial OData examples --- .../AspNet/OData/SomeOpenApiODataWebApiExample/Book.cs | 7 +++++++ .../AspNet/OData/SomeOpenApiODataWebApiExample/Startup.cs | 4 ++++ examples/AspNetCore/OData/SomeODataOpenApiExample/Book.cs | 7 +++++++ 3 files changed, 18 insertions(+) diff --git a/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Book.cs b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Book.cs index bd9fae58..a0a736b0 100644 --- a/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Book.cs +++ b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Book.cs @@ -1,8 +1,15 @@ namespace ApiVersioning.Examples; +using Microsoft.AspNet.OData.Query; + +// TODO: Model Bound settings can be performed via attributes if the +// return type is known to the API Explorer or can be explicitly done +// via one or more IModelConfiguration implementations + /// /// Represents a book. /// +[Filter( "author", "published" )] public class Book { /// diff --git a/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Startup.cs b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Startup.cs index 73be263e..dca602ca 100644 --- a/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Startup.cs +++ b/examples/AspNet/OData/SomeOpenApiODataWebApiExample/Startup.cs @@ -68,6 +68,10 @@ public void Configuration( IAppBuilder builder ) .Allow( Skip | Count ) .AllowTop( 100 ) .AllowOrderBy( "title", "published" ); + + // applies model bound settings implicitly using an ad hoc EDM. alternatively, you can create your own + // IModelConfiguration + IODataQueryOptionsConvention for full control over what goes in the ad hoc EDM. + options.AdHocModelBuilder.ModelConfigurations.Add( new ImplicitModelBoundSettingsConvention() ); } ); configuration.EnableSwagger( diff --git a/examples/AspNetCore/OData/SomeODataOpenApiExample/Book.cs b/examples/AspNetCore/OData/SomeODataOpenApiExample/Book.cs index bd9fae58..c54dcba5 100644 --- a/examples/AspNetCore/OData/SomeODataOpenApiExample/Book.cs +++ b/examples/AspNetCore/OData/SomeODataOpenApiExample/Book.cs @@ -1,8 +1,15 @@ namespace ApiVersioning.Examples; +using Microsoft.OData.ModelBuilder; + +// TODO: Model Bound settings can be performed via attributes if the +// return type is known to the API Explorer or can be explicitly done +// via one or more IModelConfiguration implementations + /// /// Represents a book. /// +[Filter( "author", "published" )] public class Book { /// From 71c9b791a3a71683bdfbcbb415a222a0f0c2f194 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Tue, 6 Dec 2022 13:55:12 -0800 Subject: [PATCH 8/8] Fix CodeQL violations --- .../PartialODataDescriptionProvider.cs | 31 +++++++++++++------ .../ImplicitModelBoundSettingsConvention.cs | 3 +- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/PartialODataDescriptionProvider.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/PartialODataDescriptionProvider.cs index 562a8469..31824b08 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/PartialODataDescriptionProvider.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/ApiExplorer/PartialODataDescriptionProvider.cs @@ -23,7 +23,7 @@ namespace Asp.Versioning.ApiExplorer; [CLSCompliant( false )] public class PartialODataDescriptionProvider : IApiDescriptionProvider { - private static int? beforeOData; + private static readonly int BeforeOData = ODataOrder() + 10; private readonly IOptionsFactory odataOptionsFactory; private readonly IOptions options; private bool markedAdHoc; @@ -42,8 +42,6 @@ public PartialODataDescriptionProvider( { this.odataOptionsFactory = odataOptionsFactory ?? throw new ArgumentNullException( nameof( odataOptionsFactory ) ); this.options = options ?? throw new ArgumentNullException( nameof( options ) ); - beforeOData ??= ODataOrder( odataOptionsFactory, options ) + 10; - Order = beforeOData.Value; } /// @@ -84,7 +82,7 @@ protected ODataApiExplorerOptions Options /// Gets or sets the order precedence of the current API description provider. /// /// The order precedence of the current API description provider. - public int Order { get; protected set; } + public int Order { get; protected set; } = BeforeOData; /// public virtual void OnProvidersExecuting( ApiDescriptionProviderContext context ) @@ -102,9 +100,9 @@ public virtual void OnProvidersExecuting( ApiDescriptionProviderContext context { var model = models[i]; var version = model.GetApiVersion(); - var options = odataOptionsFactory.Create( Opts.DefaultName ); + var odata = odataOptionsFactory.Create( Opts.DefaultName ); - options.AddRouteComponents( model ); + odata.AddRouteComponents( model ); for ( var j = 0; j < results.Count; j++ ) { @@ -139,12 +137,18 @@ public virtual void OnProvidersExecuted( ApiDescriptionProviderContext context ) } [MethodImpl( MethodImplOptions.AggressiveInlining )] - private static int ODataOrder( IOptionsFactory factory, IOptions options ) => + private static int ODataOrder() => new ODataApiDescriptionProvider( new StubModelMetadataProvider(), new StubModelTypeBuilder(), - factory, - options ).Order; + new OptionsFactory( + Enumerable.Empty>(), + Enumerable.Empty>() ), + Opts.Create( + new ODataApiExplorerOptions( + new( + new StubODataApiVersionCollectionProvider(), + Enumerable.Empty() ) ) ) ).Order; [MethodImpl( MethodImplOptions.AggressiveInlining )] private static void MarkAsAdHoc( ODataModelBuilder builder, IEdmModel model ) => @@ -206,6 +210,15 @@ public Type NewStructuredType( IEdmModel model, IEdmStructuredType structuredTyp throw new NotImplementedException(); } + private sealed class StubODataApiVersionCollectionProvider : IODataApiVersionCollectionProvider + { + public IReadOnlyList ApiVersions + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + } + private static class ODataMetadata { private const string ArbitrarySegment = "52459ff8-bca1-4a26-b7f2-08c7da04472d"; diff --git a/src/Common/src/Common.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs b/src/Common/src/Common.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs index 39dd9f3b..d9c6822f 100644 --- a/src/Common/src/Common.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs +++ b/src/Common/src/Common.OData.ApiExplorer/Conventions/ImplicitModelBoundSettingsConvention.cs @@ -75,9 +75,8 @@ public void Apply( ODataModelBuilder builder, ApiVersion apiVersion, string? rou private void OnModelCreating( ODataModelBuilder builder ) { - foreach ( var type in types ) + foreach ( var entityType in types.Select( builder.AddEntityType ) ) { - var entityType = builder.AddEntityType( type ); builder.AddEntitySet( entityType.Name, entityType ); } }