diff --git a/build/file-version.targets b/build/file-version.targets index 4f6f27a3..3c8bc9b1 100644 --- a/build/file-version.targets +++ b/build/file-version.targets @@ -7,7 +7,7 @@ $(AssemblyVersion.Split(`.`)[0]).$(AssemblyVersion.Split(`.`)[1]) $([System.DateTime]::Now.IsDaylightSavingTime()) $([System.DateTime]::Today.Subtract($([System.DateTime]::Parse("1/1/2000"))).ToString("%d")) - $([System.Convert]::ToInt32($([MSBuild]::Divide($([System.DateTime]::Now.TimeOfDay.Subtract($([System.TimeSpan]::FromHours(1.0))).TotalSeconds),2)))) + $([System.Convert]::ToInt32($([MSBuild]::Divide($([System.DateTime]::Now.Subtract($([System.TimeSpan]::FromHours(1.0))).TimeOfDay.TotalSeconds),2)))) $([System.Convert]::ToInt32($([MSBuild]::Divide($([System.DateTime]::Now.TimeOfDay.TotalSeconds),2)))) $(MajorAndMinorVersion).$(FileBuildNumber).$(FileBuildRevision) diff --git a/samples/webapi/AdvancedODataWebApiSample/Startup.cs b/samples/webapi/AdvancedODataWebApiSample/Startup.cs index 12b0f1b2..5237f5c8 100644 --- a/samples/webapi/AdvancedODataWebApiSample/Startup.cs +++ b/samples/webapi/AdvancedODataWebApiSample/Startup.cs @@ -2,12 +2,14 @@ namespace Microsoft.Examples { - using Configuration; using global::Owin; + using Microsoft.AspNet.OData; using Microsoft.AspNet.OData.Batch; using Microsoft.AspNet.OData.Builder; using Microsoft.AspNet.OData.Routing; + using Microsoft.Examples.Configuration; using Microsoft.OData; + using Microsoft.OData.UriParser; using Microsoft.Web.Http.Versioning; using System.Web.Http; using static Microsoft.OData.ODataUrlKeyDelimiter; @@ -33,7 +35,6 @@ public void Configuration( IAppBuilder appBuilder ) var modelBuilder = new VersionedODataModelBuilder( configuration ) { - ModelBuilderFactory = () => new ODataConventionModelBuilder().EnableLowerCamelCase(), ModelConfigurations = { new PersonModelConfiguration(), @@ -43,14 +44,15 @@ public void Configuration( IAppBuilder appBuilder ) var models = modelBuilder.GetEdmModels(); var batchHandler = new DefaultODataBatchHandler( httpServer ); - configuration.MapVersionedODataRoutes( "odata", "api", models, OnConfigureContainer, batchHandler ); + configuration.MapVersionedODataRoutes( "odata", "api", models, ConfigureContainer, batchHandler ); configuration.Routes.MapHttpRoute( "orders", "api/{controller}/{id}", new { id = Optional } ); appBuilder.UseWebApi( httpServer ); } - static void OnConfigureContainer( IContainerBuilder builder ) + static void ConfigureContainer( IContainerBuilder builder ) { - builder.AddService( Singleton, typeof( IODataPathHandler ), sp => new DefaultODataPathHandler() { UrlKeyDelimiter = Parentheses } ); + builder.AddService( Singleton, sp => new DefaultODataPathHandler() { UrlKeyDelimiter = Parentheses } ); + builder.AddService( Singleton, sp => new UnqualifiedCallAndEnumPrefixFreeResolver() { EnableCaseInsensitive = true } ); } } } \ No newline at end of file diff --git a/samples/webapi/BasicODataWebApiSample/Startup.cs b/samples/webapi/BasicODataWebApiSample/Startup.cs index bbc058c8..a6fdcde5 100644 --- a/samples/webapi/BasicODataWebApiSample/Startup.cs +++ b/samples/webapi/BasicODataWebApiSample/Startup.cs @@ -2,11 +2,17 @@ namespace Microsoft.Examples { - using Configuration; using global::Owin; + using Microsoft.AspNet.OData; using Microsoft.AspNet.OData.Batch; using Microsoft.AspNet.OData.Builder; + using Microsoft.AspNet.OData.Routing; + using Microsoft.Examples.Configuration; + using Microsoft.OData; + using Microsoft.OData.UriParser; using System.Web.Http; + using static Microsoft.OData.ODataUrlKeyDelimiter; + using static Microsoft.OData.ServiceLifetime; public class Startup { @@ -20,7 +26,6 @@ public void Configuration( IAppBuilder appBuilder ) var modelBuilder = new VersionedODataModelBuilder( configuration ) { - ModelBuilderFactory = () => new ODataConventionModelBuilder().EnableLowerCamelCase(), ModelConfigurations = { new PersonModelConfiguration(), @@ -30,9 +35,15 @@ public void Configuration( IAppBuilder appBuilder ) var models = modelBuilder.GetEdmModels(); var batchHandler = new DefaultODataBatchHandler( httpServer ); - configuration.MapVersionedODataRoutes( "odata", "api", models, batchHandler ); - configuration.MapVersionedODataRoutes( "odata-bypath", "v{apiVersion}", models ); + configuration.MapVersionedODataRoutes( "odata", "api", models, ConfigureContainer, batchHandler ); + configuration.MapVersionedODataRoutes( "odata-bypath", "v{apiVersion}", models, ConfigureContainer ); appBuilder.UseWebApi( httpServer ); } + + static void ConfigureContainer( IContainerBuilder builder ) + { + builder.AddService( Singleton, sp => new DefaultODataPathHandler() { UrlKeyDelimiter = Parentheses } ); + builder.AddService( Singleton, sp => new UnqualifiedCallAndEnumPrefixFreeResolver() { EnableCaseInsensitive = true } ); + } } } \ No newline at end of file diff --git a/samples/webapi/ConventionsODataWebApiSample/Startup.cs b/samples/webapi/ConventionsODataWebApiSample/Startup.cs index cfa6f343..c228297f 100644 --- a/samples/webapi/ConventionsODataWebApiSample/Startup.cs +++ b/samples/webapi/ConventionsODataWebApiSample/Startup.cs @@ -2,13 +2,19 @@ namespace Microsoft.Examples { - using Configuration; - using Controllers; using global::Owin; + using Microsoft.AspNet.OData; using Microsoft.AspNet.OData.Batch; using Microsoft.AspNet.OData.Builder; + using Microsoft.AspNet.OData.Routing; + using Microsoft.Examples.Configuration; + using Microsoft.Examples.Controllers; + using Microsoft.OData; + using Microsoft.OData.UriParser; using Microsoft.Web.Http.Versioning.Conventions; using System.Web.Http; + using static Microsoft.OData.ODataUrlKeyDelimiter; + using static Microsoft.OData.ServiceLifetime; public class Startup { @@ -38,7 +44,6 @@ public void Configuration( IAppBuilder appBuilder ) var modelBuilder = new VersionedODataModelBuilder( configuration ) { - ModelBuilderFactory = () => new ODataConventionModelBuilder().EnableLowerCamelCase(), ModelConfigurations = { new PersonModelConfiguration(), @@ -48,9 +53,15 @@ public void Configuration( IAppBuilder appBuilder ) var models = modelBuilder.GetEdmModels(); var batchHandler = new DefaultODataBatchHandler( httpServer ); - configuration.MapVersionedODataRoutes( "odata", "api", models, batchHandler ); - configuration.MapVersionedODataRoutes( "odata-bypath", "v{apiVersion}", models ); + configuration.MapVersionedODataRoutes( "odata", "api", models, ConfigureContainer, batchHandler ); + configuration.MapVersionedODataRoutes( "odata-bypath", "v{apiVersion}", models, ConfigureContainer ); appBuilder.UseWebApi( httpServer ); } + + static void ConfigureContainer( IContainerBuilder builder ) + { + builder.AddService( Singleton, sp => new DefaultODataPathHandler() { UrlKeyDelimiter = Parentheses } ); + builder.AddService( Singleton, sp => new UnqualifiedCallAndEnumPrefixFreeResolver() { EnableCaseInsensitive = true } ); + } } } \ No newline at end of file diff --git a/samples/webapi/SwaggerODataWebApiSample/Configuration/OrderModelConfiguration.cs b/samples/webapi/SwaggerODataWebApiSample/Configuration/OrderModelConfiguration.cs index 6002cb5e..c6e35b88 100644 --- a/samples/webapi/SwaggerODataWebApiSample/Configuration/OrderModelConfiguration.cs +++ b/samples/webapi/SwaggerODataWebApiSample/Configuration/OrderModelConfiguration.cs @@ -30,12 +30,12 @@ public void Apply( ODataModelBuilder builder, ApiVersion apiVersion ) if ( apiVersion >= ApiVersions.V1 ) { - // order.Collection.Function( "MostExpensive" ).ReturnsFromEntitySet( "Orders" ); + order.Collection.Function( "MostExpensive" ).ReturnsFromEntitySet( "Orders" ); } if ( apiVersion >= ApiVersions.V2 ) { - //order.Action( "Rate" ).Parameter( "rating" ); + order.Action( "Rate" ).Parameter( "rating" ); } } } diff --git a/samples/webapi/SwaggerODataWebApiSample/Startup.cs b/samples/webapi/SwaggerODataWebApiSample/Startup.cs index 5eaa3ef0..f64a7a18 100644 --- a/samples/webapi/SwaggerODataWebApiSample/Startup.cs +++ b/samples/webapi/SwaggerODataWebApiSample/Startup.cs @@ -3,15 +3,21 @@ namespace Microsoft.Examples { using global::Owin; + using Microsoft.AspNet.OData; using Microsoft.AspNet.OData.Builder; using Microsoft.AspNet.OData.Extensions; + using Microsoft.AspNet.OData.Routing; using Microsoft.Examples.Configuration; + using Microsoft.OData; + using Microsoft.OData.UriParser; using Newtonsoft.Json.Serialization; using Swashbuckle.Application; using System.IO; using System.Reflection; using System.Web.Http; using System.Web.Http.Description; + using static Microsoft.OData.ODataUrlKeyDelimiter; + using static Microsoft.OData.ServiceLifetime; /// /// Represents the startup process for the application. @@ -28,8 +34,6 @@ public void Configuration( IAppBuilder builder ) var configuration = new HttpConfiguration(); var httpServer = new HttpServer( configuration ); - configuration.SetUrlKeyDelimiter( OData.ODataUrlKeyDelimiter.Parentheses ); - // reporting api versions will return the headers "api-supported-versions" and "api-deprecated-versions" configuration.AddApiVersioning( o => o.ReportApiVersions = true ); @@ -38,7 +42,6 @@ public void Configuration( IAppBuilder builder ) var modelBuilder = new VersionedODataModelBuilder( configuration ) { - ModelBuilderFactory = () => new ODataConventionModelBuilder().EnableLowerCamelCase(), ModelConfigurations = { new AllConfigurations(), @@ -51,10 +54,10 @@ public void Configuration( IAppBuilder builder ) // TODO: while you can use both, you should choose only ONE of the following; comment, uncomment, or remove as necessary // WHEN VERSIONING BY: query string, header, or media type - configuration.MapVersionedODataRoutes( "odata", routePrefix, models ); + configuration.MapVersionedODataRoutes( "odata", routePrefix, models, ConfigureContainer ); // WHEN VERSIONING BY: url segment - //configuration.MapVersionedODataRoutes( "odata-bypath", "api/v{apiVersion}", models, ConfigureODataServices ); + //configuration.MapVersionedODataRoutes( "odata-bypath", "api/v{apiVersion}", models, ConfigureContainer ); // add the versioned IApiExplorer and capture the strongly-typed implementation (e.g. ODataApiExplorer vs IApiExplorer) // note: the specified format code will format the version as "'v'major[.minor][-status]" @@ -114,5 +117,11 @@ static string XmlCommentsFilePath return Path.Combine( basePath, fileName ); } } + + static void ConfigureContainer( IContainerBuilder builder ) + { + builder.AddService( Singleton, sp => new DefaultODataPathHandler() { UrlKeyDelimiter = Parentheses } ); + builder.AddService( Singleton, sp => new UnqualifiedCallAndEnumPrefixFreeResolver() { EnableCaseInsensitive = true } ); + } } } \ No newline at end of file diff --git a/samples/webapi/SwaggerODataWebApiSample/SwaggerODataWebApiSample.csproj b/samples/webapi/SwaggerODataWebApiSample/SwaggerODataWebApiSample.csproj index fa53b426..868707fa 100644 --- a/samples/webapi/SwaggerODataWebApiSample/SwaggerODataWebApiSample.csproj +++ b/samples/webapi/SwaggerODataWebApiSample/SwaggerODataWebApiSample.csproj @@ -84,9 +84,6 @@ ..\..\..\packages\Swashbuckle.Core.5.5.3\lib\net40\Swashbuckle.Core.dll - ..\..\..\packages\Microsoft.AspNet.WebApi.Client.5.2.3\lib\net45\System.Net.Http.Formatting.dll @@ -178,7 +175,7 @@ True 1874 / - http://localhost:1874/ + http://localhost:1874/swagger False False diff --git a/src/Common.ApiExplorer/ApiExplorerOptions.cs b/src/Common.ApiExplorer/ApiExplorerOptions.cs index 0790d11a..dc0ea6d8 100644 --- a/src/Common.ApiExplorer/ApiExplorerOptions.cs +++ b/src/Common.ApiExplorer/ApiExplorerOptions.cs @@ -53,5 +53,22 @@ public partial class ApiExplorerOptions /// The default description for API version parameters. The default value /// is "The requested API version". public string DefaultApiVersionParameterDescription { get; set; } = LocalSR.DefaultApiVersionParamDesc; + + /// + /// Gets or sets a value indicating whether API version parameters are added when an API is version-neutral. + /// + /// True if API version parameters should be included when exploring a version-neutral API; otherwise, false. + /// The default value is false. + /// + /// + /// A version-neutral API can accept any API version, including none at all. Setting this property to true + /// will enable exploring parameter descriptors for an API version that can be used to generate user input, which + /// may be useful for a version-neutral API that its own per-API version logic. + /// + /// + /// An API version defined using the URLsegment method is unaffected by this setting because path-based route + /// parameters are always required. + /// + public bool AddApiVersionParametersWhenVersionNeutral { get; set; } } } \ No newline at end of file diff --git a/src/Common.OData.ApiExplorer/AspNet.OData/ClassProperty.cs b/src/Common.OData.ApiExplorer/AspNet.OData/ClassProperty.cs index 152b6eb7..e53dc634 100644 --- a/src/Common.OData.ApiExplorer/AspNet.OData/ClassProperty.cs +++ b/src/Common.OData.ApiExplorer/AspNet.OData/ClassProperty.cs @@ -11,8 +11,8 @@ struct ClassProperty { + readonly Type type; internal readonly string Name; - internal readonly Type Type; internal ClassProperty( PropertyInfo clrProperty, Type propertyType ) { @@ -20,7 +20,7 @@ internal ClassProperty( PropertyInfo clrProperty, Type propertyType ) Contract.Requires( propertyType != null ); Name = clrProperty.Name; - Type = propertyType; + type = propertyType; Attributes = AttributesFromProperty( clrProperty ); } @@ -30,12 +30,36 @@ internal ClassProperty( IEnumerable assemblies, IEdmOperationParameter Contract.Requires( parameter != null ); Name = parameter.Name; - Type = parameter.Type.Definition.GetClrType( assemblies ); + type = parameter.Type.Definition.GetClrType( assemblies ); Attributes = AttributesFromOperationParameter( parameter ); } internal IEnumerable Attributes { get; } + public override int GetHashCode() => ( Name.GetHashCode() * 397 ) ^ type.GetHashCode(); + + public Type GetType( Type declaringType ) + { + Contract.Requires( declaringType != null ); + Contract.Ensures( Contract.Result() != null ); + + if ( type == DeclaringType.Value ) + { + return declaringType; + } + else if ( type.IsGenericType ) + { + var typeArgs = type.GetGenericArguments(); + + if ( typeArgs.Length == 1 && typeArgs[0] == DeclaringType.Value ) + { + return type.GetGenericTypeDefinition().MakeGenericType( declaringType ); + } + } + + return type; + } + static IEnumerable AttributesFromProperty( PropertyInfo clrProperty ) { Contract.Requires( clrProperty != null ); @@ -93,7 +117,5 @@ static IEnumerable AttributesFromOperationParameter( IEd yield return new CustomAttributeBuilder( ctor, args ); } - - public override int GetHashCode() => ( Name.GetHashCode() * 397 ) ^ Type.GetHashCode(); } } \ No newline at end of file diff --git a/src/Common.OData.ApiExplorer/AspNet.OData/DeclaringType.cs b/src/Common.OData.ApiExplorer/AspNet.OData/DeclaringType.cs new file mode 100644 index 00000000..db66520a --- /dev/null +++ b/src/Common.OData.ApiExplorer/AspNet.OData/DeclaringType.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNet.OData +{ + using System; + + struct DeclaringType + { + internal static Type Value = typeof( DeclaringType ); + } +} \ No newline at end of file diff --git a/src/Common.OData.ApiExplorer/AspNet.OData/DefaultModelTypeBuilder.cs b/src/Common.OData.ApiExplorer/AspNet.OData/DefaultModelTypeBuilder.cs index a19e813f..53efac86 100644 --- a/src/Common.OData.ApiExplorer/AspNet.OData/DefaultModelTypeBuilder.cs +++ b/src/Common.OData.ApiExplorer/AspNet.OData/DefaultModelTypeBuilder.cs @@ -30,6 +30,7 @@ public sealed class DefaultModelTypeBuilder : IModelTypeBuilder readonly ICollection assemblies; readonly ConcurrentDictionary modules = new ConcurrentDictionary(); readonly ConcurrentDictionary generatedTypes = new ConcurrentDictionary(); + readonly Dictionary visitedEdmTypes = new Dictionary(); /// /// Initializes a new instance of the class. @@ -49,6 +50,13 @@ public Type NewStructuredType( IEdmStructuredType structuredType, Type clrType, Arg.NotNull( apiVersion, nameof( apiVersion ) ); Contract.Ensures( Contract.Result() != null ); + var typeKey = new EdmTypeKey( structuredType, apiVersion ); + + if ( visitedEdmTypes.TryGetValue( typeKey, out var generatedType ) ) + { + return generatedType; + } + const BindingFlags bindingFlags = BindingFlags.Public | BindingFlags.Instance; var properties = new List(); @@ -63,33 +71,19 @@ public Type NewStructuredType( IEdmStructuredType structuredType, Type clrType, continue; } - var propertyType = property.PropertyType; var structuredTypeRef = structuralProperty.Type; + var propertyType = property.PropertyType; if ( structuredTypeRef.IsCollection() ) { - var collectionType = structuredTypeRef.AsCollection(); - var elementType = collectionType.ElementType(); - - if ( elementType.IsStructured() ) - { - assemblies.Add( clrType.Assembly ); - - var itemType = elementType.Definition.GetClrType( assemblies ); - var newItemType = NewStructuredType( elementType.ToStructuredType(), itemType, apiVersion ); - - if ( !itemType.Equals( newItemType ) ) - { - propertyType = IEnumerableOfT.MakeGenericType( newItemType ); - } - } + propertyType = NewStructuredTypeOrSelf( typeKey, structuredTypeRef.AsCollection(), propertyType, apiVersion ); } else if ( structuredTypeRef.IsStructured() ) { - propertyType = NewStructuredType( structuredTypeRef.ToStructuredType(), property.PropertyType, apiVersion ); + propertyType = NewStructuredTypeOrSelf( typeKey, structuredTypeRef.ToStructuredType(), propertyType, apiVersion ); } - clrTypeMatchesEdmType &= property.PropertyType.Equals( propertyType ); + clrTypeMatchesEdmType &= propertyType.IsDeclaringType() || property.PropertyType.Equals( propertyType ); properties.Add( new ClassProperty( property, propertyType ) ); } @@ -100,7 +94,10 @@ public Type NewStructuredType( IEdmStructuredType structuredType, Type clrType, var signature = new ClassSignature( clrType.FullName, properties, apiVersion ); - return generatedTypes.GetOrAdd( signature, CreateFromSignature ); + generatedType = generatedTypes.GetOrAdd( signature, CreateFromSignature ); + visitedEdmTypes.Add( typeKey, generatedType ); + + return generatedType; } /// @@ -128,10 +125,12 @@ TypeInfo CreateFromSignature( ClassSignature @class ) foreach ( var property in @class.Properties ) { - var field = typeBuilder.DefineField( "_" + property.Name, property.Type, FieldAttributes.Private ); - var propertyBuilder = typeBuilder.DefineProperty( property.Name, PropertyAttributes.HasDefault, property.Type, null ); - var getter = typeBuilder.DefineMethod( "get_" + property.Name, PropertyMethodAttributes, property.Type, Type.EmptyTypes ); - var setter = typeBuilder.DefineMethod( "set_" + property.Name, PropertyMethodAttributes, null, new[] { property.Type } ); + var type = property.GetType( typeBuilder ); + var name = property.Name; + var field = typeBuilder.DefineField( "_" + name, type, FieldAttributes.Private ); + var propertyBuilder = typeBuilder.DefineProperty( name, PropertyAttributes.HasDefault, type, null ); + var getter = typeBuilder.DefineMethod( "get_" + name, PropertyMethodAttributes, type, Type.EmptyTypes ); + var setter = typeBuilder.DefineMethod( "set_" + name, PropertyMethodAttributes, null, new[] { type } ); var il = getter.GetILGenerator(); il.Emit( OpCodes.Ldarg_0 ); @@ -156,6 +155,55 @@ TypeInfo CreateFromSignature( ClassSignature @class ) return typeBuilder.CreateTypeInfo(); } + Type NewStructuredTypeOrSelf( EdmTypeKey declaringTypeKey, IEdmCollectionTypeReference collectionType, Type clrType, ApiVersion apiVersion ) + { + Contract.Requires( collectionType != null ); + Contract.Requires( clrType != null ); + Contract.Requires( apiVersion != null ); + Contract.Ensures( Contract.Result() != null ); + + var elementType = collectionType.ElementType(); + + if ( !elementType.IsStructured() ) + { + return clrType; + } + + var structuredType = elementType.ToStructuredType(); + + if ( declaringTypeKey == new EdmTypeKey( structuredType, apiVersion ) ) + { + return IEnumerableOfT.MakeGenericType( DeclaringType.Value ); + } + + assemblies.Add( clrType.Assembly ); + + var itemType = elementType.Definition.GetClrType( assemblies ); + var newItemType = NewStructuredType( structuredType, itemType, apiVersion ); + + if ( !itemType.Equals( newItemType ) ) + { + clrType = IEnumerableOfT.MakeGenericType( newItemType ); + } + + return clrType; + } + + Type NewStructuredTypeOrSelf( EdmTypeKey declaringTypeKey, IEdmStructuredType structuredType, Type clrType, ApiVersion apiVersion ) + { + Contract.Requires( structuredType != null ); + Contract.Requires( clrType != null ); + Contract.Requires( apiVersion != null ); + Contract.Ensures( Contract.Result() != null ); + + if ( declaringTypeKey == new EdmTypeKey( structuredType, apiVersion ) ) + { + return DeclaringType.Value; + } + + return NewStructuredType( structuredType, clrType, apiVersion ); + } + static ModuleBuilder CreateModuleForApiVersion( ApiVersion apiVersion ) { var name = new AssemblyName( $"T{NewGuid().ToString( "n", InvariantCulture )}.DynamicModels" ); diff --git a/src/Common.OData.ApiExplorer/AspNet.OData/EdmTypeKey.cs b/src/Common.OData.ApiExplorer/AspNet.OData/EdmTypeKey.cs new file mode 100644 index 00000000..53afce4a --- /dev/null +++ b/src/Common.OData.ApiExplorer/AspNet.OData/EdmTypeKey.cs @@ -0,0 +1,51 @@ +namespace Microsoft.AspNet.OData +{ +#if !WEBAPI + using Microsoft.AspNetCore.Mvc; +#endif + using Microsoft.OData.Edm; +#if WEBAPI + using Microsoft.Web.Http; +#endif + using System; + using System.Diagnostics.Contracts; + + struct EdmTypeKey : IEquatable + { + readonly int hashCode; + + internal EdmTypeKey( IEdmStructuredType type, ApiVersion apiVersion ) + { + Contract.Requires( type != null ); + Contract.Requires( apiVersion != null ); + + hashCode = ComputeHash( type.FullTypeName(), apiVersion ); + } + + internal EdmTypeKey( IEdmTypeReference type, ApiVersion apiVersion ) + { + Contract.Requires( type != null ); + Contract.Requires( apiVersion != null ); + + hashCode = ComputeHash( type.FullName(), apiVersion ); + } + + public static bool operator ==( EdmTypeKey obj, EdmTypeKey other ) => obj.Equals( other ); + + public static bool operator !=( EdmTypeKey obj, EdmTypeKey other ) => !obj.Equals( other ); + + public override int GetHashCode() => hashCode; + + public override bool Equals( object obj ) => obj is EdmTypeKey other && Equals( other ); + + public bool Equals( EdmTypeKey other ) => hashCode == other.hashCode; + + static int ComputeHash( string fullName, ApiVersion apiVersion ) + { + Contract.Requires( !string.IsNullOrEmpty( fullName ) ); + Contract.Requires( apiVersion != null ); + + return ( fullName.GetHashCode() * 397 ) ^ apiVersion.GetHashCode(); + } + } +} \ No newline at end of file diff --git a/src/Common.OData.ApiExplorer/AspNet.OData/Routing/ODataRouteBuilder.cs b/src/Common.OData.ApiExplorer/AspNet.OData/Routing/ODataRouteBuilder.cs index 0e911e62..a502eff7 100644 --- a/src/Common.OData.ApiExplorer/AspNet.OData/Routing/ODataRouteBuilder.cs +++ b/src/Common.OData.ApiExplorer/AspNet.OData/Routing/ODataRouteBuilder.cs @@ -80,7 +80,7 @@ void AppendRoutePrefix( IList segments ) return; } - prefix = UpdateRoutePrefixAndRemoveApiVersionParameterIfNecessary( prefix ); + prefix = RemoveRouteConstraints( prefix ); segments.Add( prefix ); } diff --git a/src/Common.OData.ApiExplorer/AspNet.OData/TypeExtensions.cs b/src/Common.OData.ApiExplorer/AspNet.OData/TypeExtensions.cs index 9c4c6bc8..94747563 100644 --- a/src/Common.OData.ApiExplorer/AspNet.OData/TypeExtensions.cs +++ b/src/Common.OData.ApiExplorer/AspNet.OData/TypeExtensions.cs @@ -25,7 +25,6 @@ public static partial class TypeExtensions static readonly Type ActionResultType = typeof( IActionResult ); static readonly Type HttpResponseType = typeof( HttpResponseMessage ); static readonly Type IEnumerableOfT = typeof( IEnumerable<> ); - static readonly Type DeltaOfT = typeof( Delta<> ); static readonly Type ODataValueOfT = typeof( ODataValue<> ); /// @@ -56,7 +55,13 @@ public static Type SubstituteIfNecessary( this Type type, TypeSubstitutionContex } var newType = context.ModelTypeBuilder.NewStructuredType( structuredType, innerType, apiVersion ); - return innerType.Equals( newType ) ? type : CloseGeneric( openTypes, newType ); + + if ( innerType.Equals( newType ) ) + { + return type.ExtractInnerType() ? innerType : type; + } + + return CloseGeneric( openTypes, newType ); } if ( CanBeSubstituted( type ) ) @@ -77,6 +82,25 @@ internal static void Deconstruct( this Tuple tuple, out T1 item1 item2 = tuple.Item2; } + internal static bool IsDeclaringType( this Type type ) + { + Contract.Requires( type != null ); + + if ( type == DeclaringType.Value ) + { + return true; + } + + if ( !type.IsGenericType ) + { + return false; + } + + var typeArgs = type.GetGenericArguments(); + + return typeArgs.Length == 1 && typeArgs[0] == DeclaringType.Value; + } + static bool IsSubstitutableGeneric( Type type, Stack openTypes, out Type innerType ) { Contract.Requires( type != null ); @@ -101,10 +125,7 @@ static bool IsSubstitutableGeneric( Type type, Stack openTypes, out Type i var typeArg = typeArgs[0]; - if ( typeDef.Equals( IEnumerableOfT ) || - typeDef.Equals( DeltaOfT ) || - typeDef.Equals( ODataValueOfT ) || - typeDef.FullName.Equals( "Microsoft.AspNetCore.Mvc.ActionResult`1", Ordinal ) ) + if ( typeDef.Equals( IEnumerableOfT ) || typeDef.IsDelta() || typeDef.Equals( ODataValueOfT ) || typeDef.IsActionResult() ) { innerType = typeArg; } @@ -140,7 +161,14 @@ static Type CloseGeneric( Stack openTypes, Type innerType ) Contract.Requires( openTypes.Count > 0 ); Contract.Requires( innerType != null ); - var type = openTypes.Pop().MakeGenericType( innerType ); + var type = openTypes.Pop(); + + if ( type.ExtractInnerType() ) + { + return innerType; + } + + type = type.MakeGenericType( innerType ); while ( openTypes.Count > 0 ) { @@ -158,7 +186,8 @@ static bool CanBeSubstituted( Type type ) !type.IsValueType && !type.Equals( VoidType ) && !type.Equals( ActionResultType ) && - !type.Equals( HttpResponseType ); + !type.Equals( HttpResponseType ) && + !type.IsODataActionParameters(); } static bool IsEnumerable( this Type type, out Type itemType ) @@ -190,5 +219,11 @@ static bool IsEnumerable( this Type type, out Type itemType ) return false; } + + static bool IsActionResult( this Type type ) => + type.IsGenericType && + type.GetGenericTypeDefinition().FullName.Equals( "Microsoft.AspNetCore.Mvc.ActionResult`1", Ordinal ); + + static bool ExtractInnerType( this Type type ) => type.IsDelta() || type.IsActionResult(); } } \ No newline at end of file diff --git a/src/Common.OData.ApiExplorer/Common.OData.ApiExplorer.projitems b/src/Common.OData.ApiExplorer/Common.OData.ApiExplorer.projitems index 105106ec..b2e7ea36 100644 --- a/src/Common.OData.ApiExplorer/Common.OData.ApiExplorer.projitems +++ b/src/Common.OData.ApiExplorer/Common.OData.ApiExplorer.projitems @@ -11,6 +11,8 @@ + + diff --git a/src/Microsoft.AspNet.OData.Versioning.ApiExplorer/AspNet.OData/Routing/ODataRouteBuilder.cs b/src/Microsoft.AspNet.OData.Versioning.ApiExplorer/AspNet.OData/Routing/ODataRouteBuilder.cs index 56234168..b2266a45 100644 --- a/src/Microsoft.AspNet.OData.Versioning.ApiExplorer/AspNet.OData/Routing/ODataRouteBuilder.cs +++ b/src/Microsoft.AspNet.OData.Versioning.ApiExplorer/AspNet.OData/Routing/ODataRouteBuilder.cs @@ -1,37 +1,9 @@ namespace Microsoft.AspNet.OData.Routing { - using Microsoft.Web.Http.Description; - using System.Diagnostics.Contracts; - using System.Linq; - using static System.Globalization.CultureInfo; + using System; partial class ODataRouteBuilder { - string UpdateRoutePrefixAndRemoveApiVersionParameterIfNecessary( string routePrefix ) - { - Contract.Requires( !string.IsNullOrEmpty( routePrefix ) ); - Contract.Ensures( !string.IsNullOrEmpty( Contract.Result() ) ); - - var parameters = Context.ParameterDescriptions; - var parameter = parameters.FirstOrDefault( p => p.ParameterDescriptor is ApiVersionParameterDescriptor pd && pd.FromPath ); - - if ( parameter == null ) - { - return routePrefix; - } - - var apiVersionFormat = Context.Options.SubstitutionFormat; - var token = string.Concat( '{', parameter.Name, '}' ); - var value = Context.ApiVersion.ToString( apiVersionFormat, InvariantCulture ); - var newRoutePrefix = routePrefix.Replace( token, value ); - - if ( routePrefix == newRoutePrefix ) - { - return routePrefix; - } - - parameters.Remove( parameter ); - return newRoutePrefix; - } + static string RemoveRouteConstraints( string routePrefix ) => routePrefix; } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.WebApi.Versioning.ApiExplorer/Description/ApiVersionParameterDescriptionContext.cs b/src/Microsoft.AspNet.WebApi.Versioning.ApiExplorer/Description/ApiVersionParameterDescriptionContext.cs index e2814870..e801ade9 100644 --- a/src/Microsoft.AspNet.WebApi.Versioning.ApiExplorer/Description/ApiVersionParameterDescriptionContext.cs +++ b/src/Microsoft.AspNet.WebApi.Versioning.ApiExplorer/Description/ApiVersionParameterDescriptionContext.cs @@ -1,6 +1,7 @@ namespace Microsoft.Web.Http.Description { using Microsoft.Web.Http.Versioning; + using System; using System.Collections.Generic; using System.Diagnostics.Contracts; using System.Linq; @@ -18,6 +19,7 @@ public class ApiVersionParameterDescriptionContext : IApiVersionParameterDescriptionContext { readonly List parameters = new List( 1 ); + readonly Lazy versionNeutral; bool optional; /// @@ -39,6 +41,7 @@ public ApiVersionParameterDescriptionContext( ApiVersion = apiVersion; Options = options; optional = options.AssumeDefaultVersionWhenUnspecified && apiVersion == options.DefaultApiVersion; + versionNeutral = new Lazy( TestIfApiVersionNeutral ); } /// @@ -53,6 +56,12 @@ public ApiVersionParameterDescriptionContext( /// The associated API version. protected ApiVersion ApiVersion { get; } + /// + /// Gets a value indicating whether the current API is version-neutral. + /// + /// True if the current API is version-neutral; otherwise, false. + protected bool IsApiVersionNeutral => versionNeutral.Value; + /// /// Gets the options associated with the API explorer. /// @@ -78,20 +87,29 @@ bool HasPathParameter /// One of the values. public virtual void AddParameter( string name, ApiVersionParameterLocation location ) { + var add = default( Action ); + switch ( location ) { case Query: - AddQueryString( name ); + add = AddQueryString; break; case Header: - AddHeader( name ); + add = AddHeader; break; case Path: UpdateUrlSegment(); - break; + return; case MediaTypeParameter: - AddMediaTypeParameter( name ); + add = AddMediaTypeParameter; break; + default: + return; + } + + if ( Options.AddApiVersionParametersWhenVersionNeutral || !IsApiVersionNeutral ) + { + add( name ); } } @@ -188,6 +206,14 @@ ApiParameterDescription NewApiVersionParameter( string name, ApiParameterSource return parameter; } + bool TestIfApiVersionNeutral() + { + var action = ApiDescription.ActionDescriptor; + var model = action.GetApiVersionModel(); + + return model.IsApiVersionNeutral || ( model.DeclaredApiVersions.Count == 0 && action.ControllerDescriptor.IsApiVersionNeutral() ); + } + void RemoveAllParametersExcept( ApiParameterDescription parameter ) { // note: in a scenario where multiple api version parameters are allowed, we can remove all other parameters because diff --git a/src/Microsoft.AspNet.WebApi.Versioning.ApiExplorer/Description/VersionedApiExplorer.cs b/src/Microsoft.AspNet.WebApi.Versioning.ApiExplorer/Description/VersionedApiExplorer.cs index 6e2dc63b..f6962d75 100644 --- a/src/Microsoft.AspNet.WebApi.Versioning.ApiExplorer/Description/VersionedApiExplorer.cs +++ b/src/Microsoft.AspNet.WebApi.Versioning.ApiExplorer/Description/VersionedApiExplorer.cs @@ -271,7 +271,10 @@ protected virtual ApiDescriptionGroupCollection InitializeApiDescriptions() if ( Options.SubstituteApiVersionInUrl ) { - UpdateRelativePathAndRemoveApiVersionParameterIfNecessary( apiDescriptionGroup, Options.SubstitutionFormat ); + foreach ( var apiDescription in apiDescriptionGroup.ApiDescriptions ) + { + apiDescription.TryUpdateRelativePathAndRemoveApiVersionParameter( Options ); + } } } @@ -394,32 +397,6 @@ protected virtual bool TryExpandUriParameters( IHttpRoute route, IParsedRoute pa return true; } - static void UpdateRelativePathAndRemoveApiVersionParameterIfNecessary( ApiDescriptionGroup apiDescriptionGroup, string apiVersionFormat ) - { - Contract.Requires( apiDescriptionGroup != null ); - - foreach ( var apiDescription in apiDescriptionGroup.ApiDescriptions ) - { - var parameter = apiDescription.ParameterDescriptions.FirstOrDefault( p => p.ParameterDescriptor is ApiVersionParameterDescriptor pd && pd.FromPath ); - - if ( parameter == null ) - { - continue; - } - - var relativePath = apiDescription.RelativePath; - var token = '{' + parameter.ParameterDescriptor.ParameterName + '}'; - var value = apiDescription.ApiVersion.ToString( apiVersionFormat, InvariantCulture ); - var newRelativePath = relativePath.Replace( token, value ); - - if ( relativePath != newRelativePath ) - { - apiDescription.RelativePath = newRelativePath; - apiDescription.ParameterDescriptions.Remove( parameter ); - } - } - } - static IEnumerable FlattenRoutes( IEnumerable routes ) { Contract.Requires( routes != null ); @@ -691,14 +668,6 @@ protected virtual void PopulateApiVersionParameters( ApiDescription apiDescripti Arg.NotNull( apiDescription, nameof( apiDescription ) ); Arg.NotNull( apiVersion, nameof( apiVersion ) ); - var action = apiDescription.ActionDescriptor; - var model = action.GetApiVersionModel(); - - if ( model.IsApiVersionNeutral || ( model.DeclaredApiVersions.Count == 0 && action.ControllerDescriptor.IsApiVersionNeutral() ) ) - { - return; - } - var parameterSource = Options.ApiVersionParameterSource; var context = new ApiVersionParameterDescriptionContext( apiDescription, apiVersion, Options ); diff --git a/src/Microsoft.AspNet.WebApi.Versioning.ApiExplorer/System.Web.Http/Description/ApiDescriptionExtensions.cs b/src/Microsoft.AspNet.WebApi.Versioning.ApiExplorer/System.Web.Http/Description/ApiDescriptionExtensions.cs index f1b012d9..6c2adcb5 100644 --- a/src/Microsoft.AspNet.WebApi.Versioning.ApiExplorer/System.Web.Http/Description/ApiDescriptionExtensions.cs +++ b/src/Microsoft.AspNet.WebApi.Versioning.ApiExplorer/System.Web.Http/Description/ApiDescriptionExtensions.cs @@ -3,6 +3,8 @@ using Microsoft; using Microsoft.Web.Http.Description; using System.Diagnostics.Contracts; + using System.Linq; + using static System.Globalization.CultureInfo; /// /// Provides extension methods for the class. @@ -49,6 +51,44 @@ public static string GetUniqueID( this ApiDescription apiDescription ) return apiDescription.ID; } + /// + /// Attempts to update the relate path of the specified API description and remove the corresponding parameter according to the specified options. + /// + /// The API description to attempt to update. + /// The current API Explorer options. + /// True if the API description was updated; otherwise, false. + public static bool TryUpdateRelativePathAndRemoveApiVersionParameter( this ApiDescription apiDescription, ApiExplorerOptions options ) + { + Arg.NotNull( apiDescription, nameof( apiDescription ) ); + Arg.NotNull( options, nameof( options ) ); + + if ( !options.SubstituteApiVersionInUrl || !( apiDescription is VersionedApiDescription versionedApiDescription ) ) + { + return false; + } + + var parameter = versionedApiDescription.ParameterDescriptions.FirstOrDefault( p => p.ParameterDescriptor is ApiVersionParameterDescriptor pd && pd.FromPath ); + + if ( parameter == null ) + { + return false; + } + + var relativePath = apiDescription.RelativePath; + var token = '{' + parameter.ParameterDescriptor.ParameterName + '}'; + var value = versionedApiDescription.ApiVersion.ToString( options.SubstitutionFormat, InvariantCulture ); + var newRelativePath = relativePath.Replace( token, value ); + + if ( relativePath == newRelativePath ) + { + return false; + } + + apiDescription.RelativePath = newRelativePath; + apiDescription.ParameterDescriptions.Remove( parameter ); + return true; + } + /// /// Gets a property of the specified type from the API description. /// @@ -59,12 +99,12 @@ public static T GetProperty( this VersionedApiDescription apiDescription ) { Arg.NotNull( apiDescription, nameof( apiDescription ) ); - if ( apiDescription.Properties.TryGetValue( typeof( T ), out object value ) ) + if ( apiDescription.Properties.TryGetValue( typeof( T ), out var value ) ) { return (T) value; } - return default( T ); + return default; } /// diff --git a/src/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer/ApiDescriptionExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer/ApiDescriptionExtensions.cs index 3cd5b468..76c13ac7 100644 --- a/src/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer/ApiDescriptionExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer/ApiDescriptionExtensions.cs @@ -3,7 +3,11 @@ using System; using System.ComponentModel; using System.Diagnostics.Contracts; + using System.Linq; + using static Microsoft.AspNetCore.Mvc.ModelBinding.BindingSource; using static System.ComponentModel.EditorBrowsableState; + using static System.Globalization.CultureInfo; + using static System.Linq.Enumerable; /// /// Provides extension methods for the class. @@ -26,6 +30,44 @@ public static class ApiDescriptionExtensions [EditorBrowsable( Never )] public static void SetApiVersion( this ApiDescription apiDescription, ApiVersion apiVersion ) => apiDescription.SetProperty( apiVersion ); + /// + /// Attempts to update the relate path of the specified API description and remove the corresponding parameter according to the specified options. + /// + /// The API description to attempt to update. + /// The current API Explorer options. + /// True if the API description was updated; otherwise, false. + public static bool TryUpdateRelativePathAndRemoveApiVersionParameter( this ApiDescription apiDescription, ApiExplorerOptions options ) + { + Arg.NotNull( apiDescription, nameof( apiDescription ) ); + Arg.NotNull( options, nameof( options ) ); + + if ( !options.SubstituteApiVersionInUrl ) + { + return false; + } + + var parameter = apiDescription.ParameterDescriptions.FirstOrDefault( pd => pd.Source == Path && pd.ModelMetadata?.DataTypeName == nameof( ApiVersion ) ); + + if ( parameter == null ) + { + return false; + } + + var relativePath = apiDescription.RelativePath; + var token = '{' + parameter.Name + '}'; + var value = apiDescription.GetApiVersion().ToString( options.SubstitutionFormat, InvariantCulture ); + var newRelativePath = relativePath.Replace( token, value ); + + if ( relativePath == newRelativePath ) + { + return false; + } + + apiDescription.RelativePath = newRelativePath; + apiDescription.ParameterDescriptions.Remove( parameter ); + return true; + } + /// /// Creates a shallow copy of the current API description. /// diff --git a/src/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer/ApiVersionParameterDescriptionContext.cs b/src/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer/ApiVersionParameterDescriptionContext.cs index d5b3dfaf..128b544b 100644 --- a/src/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer/ApiVersionParameterDescriptionContext.cs +++ b/src/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer/ApiVersionParameterDescriptionContext.cs @@ -1,5 +1,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer { + using Microsoft.AspNetCore.Mvc.Abstractions; + using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.Versioning; @@ -17,6 +19,7 @@ public class ApiVersionParameterDescriptionContext : IApiVersionParameterDescriptionContext { readonly List parameters = new List( 1 ); + readonly Lazy versionNeutral; bool optional; /// @@ -43,6 +46,7 @@ public ApiVersionParameterDescriptionContext( ModelMetadata = modelMetadata; Options = options; optional = options.AssumeDefaultVersionWhenUnspecified && apiVersion == options.DefaultApiVersion; + versionNeutral = new Lazy( TestIfApiVersionNeutral ); } /// @@ -58,6 +62,12 @@ public ApiVersionParameterDescriptionContext( /// The associated API version. protected ApiVersion ApiVersion { get; } + /// + /// Gets a value indicating whether the current API is version-neutral. + /// + /// True if the current API is version-neutral; otherwise, false. + protected bool IsApiVersionNeutral => versionNeutral.Value; + /// /// Gets the model metadata for API version parameters. /// @@ -93,20 +103,29 @@ where constraints.OfType().Any() /// One of the values. public virtual void AddParameter( string name, ApiVersionParameterLocation location ) { + var add = default( Action ); + switch ( location ) { case Query: - AddQueryString( name ); + add = AddQueryString; break; case Header: - AddHeader( name ); + add = AddHeader; break; case Path: UpdateUrlSegment(); - break; + return; case MediaTypeParameter: - AddMediaTypeParameter( name ); + add = AddMediaTypeParameter; break; + default: + return; + } + + if ( Options.AddApiVersionParametersWhenVersionNeutral || !IsApiVersionNeutral ) + { + add( name ); } } @@ -237,6 +256,26 @@ ApiParameterDescription NewApiVersionParameter( string name, BindingSource sourc return parameter; } + bool TestIfApiVersionNeutral() + { + var action = ApiDescription.ActionDescriptor; + var model = action.GetProperty(); + + if ( model.IsApiVersionNeutral ) + { + return true; + } + + if ( model.DeclaredApiVersions.Count > 0 ) + { + return false; + } + + model = action.GetProperty()?.GetProperty(); + + return model?.IsApiVersionNeutral == true; + } + void RemoveAllParametersExcept( ApiParameterDescription parameter ) { // note: in a scenario where multiple api version parameters are allowed, we can remove all other parameters because diff --git a/src/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer/VersionedApiDescriptionProvider.cs b/src/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer/VersionedApiDescriptionProvider.cs index ece1d95d..833b69b8 100644 --- a/src/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer/VersionedApiDescriptionProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer/VersionedApiDescriptionProvider.cs @@ -97,23 +97,6 @@ protected virtual void PopulateApiVersionParameters( ApiDescription apiDescripti Arg.NotNull( apiDescription, nameof( apiDescription ) ); Arg.NotNull( apiVersion, nameof( apiVersion ) ); - var action = apiDescription.ActionDescriptor; - var model = action.GetProperty(); - - if ( model.IsApiVersionNeutral ) - { - return; - } - else if ( model.DeclaredApiVersions.Count == 0 ) - { - model = action.GetProperty()?.GetProperty(); - - if ( model?.IsApiVersionNeutral == true ) - { - return; - } - } - var parameterSource = Options.ApiVersionParameterSource; var context = new ApiVersionParameterDescriptionContext( apiDescription, apiVersion, modelMetadata.Value, Options ); @@ -155,12 +138,7 @@ public virtual void OnProvidersExecuted( ApiDescriptionProviderContext context ) groupResult.GroupName = groupName; groupResult.SetApiVersion( version ); PopulateApiVersionParameters( groupResult, version ); - - if ( Options.SubstituteApiVersionInUrl ) - { - UpdateRelativePathAndRemoveApiVersionParameterIfNecessary( groupResult, Options.SubstitutionFormat ); - } - + groupResult.TryUpdateRelativePathAndRemoveApiVersionParameter( Options ); groupResults.Add( groupResult ); } } @@ -180,29 +158,6 @@ public virtual void OnProvidersExecuted( ApiDescriptionProviderContext context ) /// The default implementation performs no operation. public virtual void OnProvidersExecuting( ApiDescriptionProviderContext context ) { } - static void UpdateRelativePathAndRemoveApiVersionParameterIfNecessary( ApiDescription apiDescription, string apiVersionFormat ) - { - Contract.Requires( apiDescription != null ); - - var parameter = apiDescription.ParameterDescriptions.FirstOrDefault( pd => pd.Source == BindingSource.Path && pd.ModelMetadata?.DataTypeName == nameof( ApiVersion ) ); - - if ( parameter == null ) - { - return; - } - - var relativePath = apiDescription.RelativePath; - var token = '{' + parameter.Name + '}'; - var value = apiDescription.GetApiVersion().ToString( apiVersionFormat, InvariantCulture ); - var newRelativePath = relativePath.Replace( token, value ); - - if ( relativePath != newRelativePath ) - { - apiDescription.RelativePath = newRelativePath; - apiDescription.ParameterDescriptions.Remove( parameter ); - } - } - IEnumerable FlattenApiVersions( IEnumerable descriptions ) { Contract.Requires( descriptions != null ); diff --git a/src/Microsoft.AspNetCore.OData.Versioning.ApiExplorer/AspNet.OData/Routing/ODataRouteBuilder.cs b/src/Microsoft.AspNetCore.OData.Versioning.ApiExplorer/AspNet.OData/Routing/ODataRouteBuilder.cs index 6b517ccd..337b13aa 100644 --- a/src/Microsoft.AspNetCore.OData.Versioning.ApiExplorer/AspNet.OData/Routing/ODataRouteBuilder.cs +++ b/src/Microsoft.AspNetCore.OData.Versioning.ApiExplorer/AspNet.OData/Routing/ODataRouteBuilder.cs @@ -1,11 +1,8 @@ namespace Microsoft.AspNet.OData.Routing { - using Microsoft.AspNetCore.Mvc; - using Microsoft.AspNetCore.Mvc.ModelBinding; + using Microsoft.AspNetCore.Routing.Template; using System.Collections.Generic; using System.Diagnostics.Contracts; - using System.Linq; - using static System.Globalization.CultureInfo; using static System.String; partial class ODataRouteBuilder @@ -17,31 +14,34 @@ internal string BuildPath() return Join( "/", segments ); } - string UpdateRoutePrefixAndRemoveApiVersionParameterIfNecessary( string routePrefix ) + static string RemoveRouteConstraints( string routePrefix ) { Contract.Requires( !IsNullOrEmpty( routePrefix ) ); Contract.Ensures( !IsNullOrEmpty( Contract.Result() ) ); - var parameters = Context.ParameterDescriptions; - var parameter = parameters.FirstOrDefault( pd => pd.Source == BindingSource.Path && pd.ModelMetadata?.DataTypeName == nameof( ApiVersion ) ); + var parsedTemplate = TemplateParser.Parse( routePrefix ); + var segments = new List( parsedTemplate.Segments.Count ); - if ( parameter == null ) + foreach ( var segment in parsedTemplate.Segments ) { - return routePrefix; - } + var currentSegment = Empty; - var apiVersionFormat = Context.Options.SubstitutionFormat; - var token = Concat( '{', parameter.Name, '}' ); - var value = Context.ApiVersion.ToString( apiVersionFormat, InvariantCulture ); - var newRoutePrefix = routePrefix.Replace( token, value ); + foreach ( var part in segment.Parts ) + { + if ( part.IsLiteral ) + { + currentSegment += part.Text; + } + else if ( part.IsParameter ) + { + currentSegment += Concat( "{", part.Name, "}" ); + } + } - if ( routePrefix == newRoutePrefix ) - { - return routePrefix; + segments.Add( currentSegment ); } - parameters.Remove( parameter ); - return newRoutePrefix; + return Join( "/", segments ); } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData.Versioning.ApiExplorer/AspNetCore.Mvc.ApiExplorer/ODataApiDescriptionProvider.cs b/src/Microsoft.AspNetCore.OData.Versioning.ApiExplorer/AspNetCore.Mvc.ApiExplorer/ODataApiDescriptionProvider.cs index bf6d8966..cb2b386a 100644 --- a/src/Microsoft.AspNetCore.OData.Versioning.ApiExplorer/AspNetCore.Mvc.ApiExplorer/ODataApiDescriptionProvider.cs +++ b/src/Microsoft.AspNetCore.OData.Versioning.ApiExplorer/AspNetCore.Mvc.ApiExplorer/ODataApiDescriptionProvider.cs @@ -11,7 +11,10 @@ using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; + using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.Versioning; + using Microsoft.AspNetCore.Routing; + using Microsoft.AspNetCore.Routing.Template; using Microsoft.Extensions.Options; using Microsoft.OData.Edm; using System; @@ -54,12 +57,14 @@ public class ODataApiDescriptionProvider : IApiDescriptionProvider /// /// The OData route collection provider associated with the description provider. /// The application part manager containing the configured application parts. + /// The inline constraint resolver used to parse route template constraints. /// The provider used to retrieve model metadata. /// The container of configured API explorer options. /// A holder containing the current MVC options. public ODataApiDescriptionProvider( IODataRouteCollectionProvider routeCollectionProvider, ApplicationPartManager partManager, + IInlineConstraintResolver inlineConstraintResolver, IModelMetadataProvider metadataProvider, IOptions options, IOptions mvcOptions ) @@ -73,6 +78,7 @@ public ODataApiDescriptionProvider( RouteCollectionProvider = routeCollectionProvider; Assemblies = partManager.ApplicationParts.OfType().Select( p => p.Assembly ).ToArray(); ModelTypeBuilder = new DefaultModelTypeBuilder( Assemblies ); + ConstraintResolver = inlineConstraintResolver; MetadataProvider = metadataProvider; this.options = options; MvcOptions = mvcOptions.Value; @@ -99,6 +105,12 @@ public ODataApiDescriptionProvider( /// The provider used to retrieve model metadata. protected IModelMetadataProvider MetadataProvider { get; } + /// + /// Gets the object used to resolve inline constraints. + /// + /// The associated inline constraint resolver. + protected IInlineConstraintResolver ConstraintResolver { get; } + /// /// Gets the options associated with the API explorer. /// @@ -200,23 +212,6 @@ protected virtual void PopulateApiVersionParameters( ApiDescription apiDescripti Arg.NotNull( apiDescription, nameof( apiDescription ) ); Arg.NotNull( apiVersion, nameof( apiVersion ) ); - var action = apiDescription.ActionDescriptor; - var model = action.GetProperty(); - - if ( model.IsApiVersionNeutral ) - { - return; - } - else if ( model.DeclaredApiVersions.Count == 0 ) - { - model = action.GetProperty()?.GetProperty(); - - if ( model?.IsApiVersionNeutral == true ) - { - return; - } - } - var parameterSource = Options.ApiVersionParameterSource; var context = new ApiVersionParameterDescriptionContext( apiDescription, apiVersion, modelMetadata.Value, Options ); @@ -315,6 +310,24 @@ static IReadOnlyList GetResponseMetadataAttributes return action.FilterDescriptors.Select( fd => fd.Filter ).OfType().ToArray(); } + static string BuildRelativePath( ControllerActionDescriptor action, ODataRouteBuilderContext routeContext ) + { + Contract.Requires( action != null ); + Contract.Requires( routeContext != null ); + Contract.Ensures( !string.IsNullOrEmpty( Contract.Result() ) ); + + var relativePath = action.AttributeRouteInfo?.Template; + + // note: if path happens to be built adhead of time, it's expected to be qualified; rebuild it as necessary + if ( string.IsNullOrEmpty( relativePath ) || !routeContext.Options.UseQualifiedOperationNames ) + { + var builder = new ODataRouteBuilder( routeContext ); + relativePath = builder.Build(); + } + + return relativePath; + } + IEnumerable NewODataApiDescriptions( ControllerActionDescriptor action, string groupName, ODataRouteMapping mapping ) { Contract.Requires( action != null ); @@ -364,6 +377,7 @@ IEnumerable NewODataApiDescriptions( ControllerActionDescriptor PopulateApiVersionParameters( apiDescription, mapping.ApiVersion ); apiDescription.SetApiVersion( mapping.ApiVersion ); + apiDescription.TryUpdateRelativePathAndRemoveApiVersionParameter( Options ); yield return apiDescription; } } @@ -411,6 +425,8 @@ IList GetParameters( ApiParameterContext context ) } } + ProcessRouteParameters( context ); + return context.Results; } @@ -602,22 +618,81 @@ IReadOnlyList GetApiResponseTypes( IReadOnlyList new ApiVersionModelMetadata( MetadataProvider, Options.DefaultApiVersionParameterDescription ); - static string BuildRelativePath( ControllerActionDescriptor action, ODataRouteBuilderContext routeContext ) + void ProcessRouteParameters( ApiParameterContext context ) { - Contract.Requires( action != null ); - Contract.Requires( routeContext != null ); - Contract.Ensures( !string.IsNullOrEmpty( Contract.Result() ) ); + var prefix = context.RouteContext.Route.RoutePrefix; - var relativePath = action.AttributeRouteInfo?.Template; + if ( string.IsNullOrEmpty( prefix ) ) + { + return; + } - // note: if path happens to be built adhead of time, it's expected to be qualified; rebuild it as necessary - if ( string.IsNullOrEmpty( relativePath ) || !routeContext.Options.UseQualifiedOperationNames ) + var routeTemplate = TemplateParser.Parse( prefix ); + var routeParameters = new Dictionary( StringComparer.OrdinalIgnoreCase ); + + foreach ( var routeParameter in routeTemplate.Parameters ) { - var builder = new ODataRouteBuilder( routeContext ); - relativePath = builder.Build(); + routeParameters.Add( routeParameter.Name, CreateRouteInfo( routeParameter ) ); } - return relativePath; + foreach ( var parameter in context.Results ) + { + if ( parameter.Source == Path || parameter.Source == ModelBinding || parameter.Source == Custom ) + { + if ( routeParameters.TryGetValue( parameter.Name, out var routeInfo ) ) + { + parameter.RouteInfo = routeInfo; + routeParameters.Remove( parameter.Name ); + + if ( parameter.Source == ModelBinding && !parameter.RouteInfo.IsOptional ) + { + parameter.Source = Path; + } + } + } + } + + foreach ( var routeParameter in routeParameters ) + { + var result = new ApiParameterDescription() + { + Name = routeParameter.Key, + RouteInfo = routeParameter.Value, + Source = Path, + }; + + context.Results.Add( result ); + + if ( !routeParameter.Value.Constraints.OfType().Any() ) + { + continue; + } + + var metadata = NewModelMetadata(); + + result.ModelMetadata = metadata; + result.Type = metadata.ModelType; + } + } + + ApiParameterRouteInfo CreateRouteInfo( TemplatePart routeParameter ) + { + var constraints = new List(); + + if ( routeParameter.InlineConstraints != null ) + { + foreach ( var constraint in routeParameter.InlineConstraints ) + { + constraints.Add( ConstraintResolver.ResolveConstraint( constraint.Constraint ) ); + } + } + + return new ApiParameterRouteInfo() + { + Constraints = constraints, + DefaultValue = routeParameter.DefaultValue, + IsOptional = routeParameter.IsOptional || routeParameter.DefaultValue != null, + }; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData.Versioning.ApiExplorer/AspNetCore.Mvc.ApiExplorer/PseudoModelBindingVisitor.cs b/src/Microsoft.AspNetCore.OData.Versioning.ApiExplorer/AspNetCore.Mvc.ApiExplorer/PseudoModelBindingVisitor.cs index 0daed33d..6b0ef28f 100644 --- a/src/Microsoft.AspNetCore.OData.Versioning.ApiExplorer/AspNetCore.Mvc.ApiExplorer/PseudoModelBindingVisitor.cs +++ b/src/Microsoft.AspNetCore.OData.Versioning.ApiExplorer/AspNetCore.Mvc.ApiExplorer/PseudoModelBindingVisitor.cs @@ -24,11 +24,11 @@ internal PseudoModelBindingVisitor( ApiParameterContext context, ParameterDescri internal ParameterDescriptor Parameter { get; } - private HashSet Visited { get; } = new HashSet( new PropertyKeyEqualityComparer() ); + HashSet Visited { get; } = new HashSet( new PropertyKeyEqualityComparer() ); internal void WalkParameter( ApiParameterDescriptionContext context ) => Visit( context, BindingSource.ModelBinding, containerName: string.Empty ); - private static string GetName( string containerName, ApiParameterDescriptionContext metadata ) + static string GetName( string containerName, ApiParameterDescriptionContext metadata ) { if ( string.IsNullOrEmpty( metadata.BinderModelName ) ) { @@ -38,7 +38,7 @@ private static string GetName( string containerName, ApiParameterDescriptionCont return metadata.BinderModelName; } - private void Visit( ApiParameterDescriptionContext bindingContext, BindingSource ambientSource, string containerName ) + void Visit( ApiParameterDescriptionContext bindingContext, BindingSource ambientSource, string containerName ) { var source = bindingContext.BindingSource; diff --git a/src/Microsoft.AspNetCore.OData.Versioning/AspNet.OData/Routing/ODataRouteBuilder.Core.cs b/src/Microsoft.AspNetCore.OData.Versioning/AspNet.OData/Routing/ODataRouteBuilder.Core.cs index a52df3c4..83331289 100644 --- a/src/Microsoft.AspNetCore.OData.Versioning/AspNet.OData/Routing/ODataRouteBuilder.Core.cs +++ b/src/Microsoft.AspNetCore.OData.Versioning/AspNet.OData/Routing/ODataRouteBuilder.Core.cs @@ -1,6 +1,8 @@ namespace Microsoft.AspNet.OData.Routing { + using Microsoft.AspNetCore.Routing.Template; using System.Collections.Generic; + using System.Diagnostics.Contracts; using static System.String; partial class ODataRouteBuilder @@ -19,6 +21,34 @@ internal string BuildPath( bool includePrefix ) return Join( "/", segments ); } - static string UpdateRoutePrefixAndRemoveApiVersionParameterIfNecessary( string routePrefix ) => routePrefix; + static string RemoveRouteConstraints( string routePrefix ) + { + Contract.Requires( !IsNullOrEmpty( routePrefix ) ); + Contract.Ensures( !IsNullOrEmpty( Contract.Result() ) ); + + var parsedTemplate = TemplateParser.Parse( routePrefix ); + var segments = new List( parsedTemplate.Segments.Count ); + + foreach ( var segment in parsedTemplate.Segments ) + { + var currentSegment = Empty; + + foreach ( var part in segment.Parts ) + { + if ( part.IsLiteral ) + { + currentSegment += part.Text; + } + else if ( part.IsParameter ) + { + currentSegment += Concat( "{", part.Name, "}" ); + } + } + + segments.Add( currentSegment ); + } + + return Join( "/", segments ); + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.OData.Versioning.ApiExplorer.Tests/AspNet.OData/Company.cs b/test/Microsoft.AspNet.OData.Versioning.ApiExplorer.Tests/AspNet.OData/Company.cs new file mode 100644 index 00000000..2b696bd5 --- /dev/null +++ b/test/Microsoft.AspNet.OData.Versioning.ApiExplorer.Tests/AspNet.OData/Company.cs @@ -0,0 +1,18 @@ +namespace Microsoft.AspNet.OData +{ + using System; + using System.Collections.Generic; + + public class Company + { + public int CompanyId { get; set; } + + public Company ParentCompany { get; set; } + + public List Subsidiaries { get; set; } + + public string Name { get; set; } + + public DateTime DateFounded { get; set; } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.OData.Versioning.ApiExplorer.Tests/AspNet.OData/DefaultModelTypeBuilderTest.cs b/test/Microsoft.AspNet.OData.Versioning.ApiExplorer.Tests/AspNet.OData/DefaultModelTypeBuilderTest.cs index 837bfd96..02989db4 100644 --- a/test/Microsoft.AspNet.OData.Versioning.ApiExplorer.Tests/AspNet.OData/DefaultModelTypeBuilderTest.cs +++ b/test/Microsoft.AspNet.OData.Versioning.ApiExplorer.Tests/AspNet.OData/DefaultModelTypeBuilderTest.cs @@ -31,6 +31,51 @@ public void substituted_type_should_be_same_as_original_type( Type originalType substitutedType.Should().Be( originalType ); } + [Fact] + public void substituted_type_should_be_extracted_from_parent_generic() + { + // arrange + var modelBuilder = new ODataConventionModelBuilder(); + + modelBuilder.EntitySet( "Contacts" ); + modelBuilder.EntityType
(); + + var context = NewContext( modelBuilder.GetEdmModel() ); + var originalType = typeof( Delta ); + + // act + var substitutedType = originalType.SubstituteIfNecessary( context ); + + // assert + substitutedType.Should().Be( typeof( Contact ) ); + } + + [Fact] + public void type_should_be_match_edm_when_extracted_and_substituted_from_parent_generic() + { + // arrange + var modelBuilder = new ODataConventionModelBuilder(); + var contact = modelBuilder.EntitySet( "Contacts" ).EntityType; + + contact.Ignore( p => p.Email ); + contact.Ignore( p => p.Phone ); + contact.Ignore( p => p.Addresses ); + + var context = NewContext( modelBuilder.GetEdmModel() ); + var originalType = typeof( Delta ); + + // act + var substitutedType = originalType.SubstituteIfNecessary( context ); + + // assert + substitutedType.Should().NotBe( originalType ); + substitutedType.Should().NotBe( typeof( Contact ) ); + substitutedType.GetRuntimeProperties().Should().HaveCount( 3 ); + substitutedType.Should().HaveProperty( nameof( Contact.ContactId ) ); + substitutedType.Should().HaveProperty( nameof( Contact.FirstName ) ); + substitutedType.Should().HaveProperty( nameof( Contact.LastName ) ); + } + [Theory] [MemberData( nameof( SubstitutionData ) )] public void type_should_match_edm_with_top_entity_substitution( Type originalType ) @@ -87,6 +132,47 @@ public void type_should_match_edm_with_nested_entity_substitution() innerType.Should().HaveProperty( nameof( Contact.LastName ) ); } + [Fact] + public void type_should_match_with_self_referencing_property_substitution() + { + // arrange + var modelBuilder = new ODataConventionModelBuilder(); + + modelBuilder.EntitySet( "Companies" ); + + var context = NewContext( modelBuilder.GetEdmModel() ); + var originalType = typeof( Company ); + + //act + var subsitutedType = originalType.SubstituteIfNecessary( context ); + + // assert + subsitutedType.Should().Be( typeof( Company ) ); + } + + [Fact] + public void type_should_use_self_referencing_property_substitution() + { + // arrange + var modelBuilder = new ODataConventionModelBuilder(); + var company = modelBuilder.EntitySet( "Companies" ).EntityType; + + company.Ignore( c => c.DateFounded ); + + var context = NewContext( modelBuilder.GetEdmModel() ); + var originalType = typeof( Company ); + + //act + var subsitutedType = originalType.SubstituteIfNecessary( context ); + + // assert + subsitutedType.GetRuntimeProperties().Should().HaveCount( 4 ); + subsitutedType.Should().HaveProperty( nameof( Company.CompanyId ) ); + subsitutedType.Should().HaveProperty( nameof( Company.Name ) ); + subsitutedType.Should().Be( subsitutedType.GetRuntimeProperty( nameof( Company.ParentCompany ) ).PropertyType ); + subsitutedType.Should().Be( subsitutedType.GetRuntimeProperty( nameof( Company.Subsidiaries ) ).PropertyType.GetGenericArguments()[0] ); + } + [Theory] [MemberData( nameof( SubstitutionData ) )] public void type_should_match_edm_with_child_entity_substitution( Type originalType ) @@ -113,7 +199,7 @@ public void type_should_match_edm_with_child_entity_substitution( Type originalT nextType.Should().HaveProperty( nameof( Contact.LastName ) ); nextType.Should().HaveProperty( nameof( Contact.Email ) ); nextType.Should().HaveProperty( nameof( Contact.Phone ) ); - nextType = nextType.GetRuntimeProperties().Single( p => p.Name == nameof( Contact.Addresses ) ).PropertyType.GetGenericArguments()[0]; + nextType = nextType.GetRuntimeProperty( nameof( Contact.Addresses ) ).PropertyType.GetGenericArguments()[0]; nextType.GetRuntimeProperties().Should().HaveCount( 5 ); nextType.Should().HaveProperty( nameof( Address.AddressId ) ); nextType.Should().HaveProperty( nameof( Address.Street ) ); @@ -156,7 +242,6 @@ public static IEnumerable SubstitutionNotRequiredData yield return new object[] { typeof( IEnumerable ) }; yield return new object[] { typeof( IEnumerable ) }; yield return new object[] { typeof( ODataValue> ) }; - yield return new object[] { typeof( Delta ) }; } } @@ -166,7 +251,6 @@ public static IEnumerable SubstitutionData { yield return new object[] { typeof( IEnumerable ) }; yield return new object[] { typeof( ODataValue ) }; - yield return new object[] { typeof( Delta ) }; } } diff --git a/test/Microsoft.AspNet.WebApi.Versioning.ApiExplorer.Tests/Description/ApiVersionParameterDescriptionContextTest.cs b/test/Microsoft.AspNet.WebApi.Versioning.ApiExplorer.Tests/Description/ApiVersionParameterDescriptionContextTest.cs index e2d2631c..64cea7f1 100644 --- a/test/Microsoft.AspNet.WebApi.Versioning.ApiExplorer.Tests/Description/ApiVersionParameterDescriptionContextTest.cs +++ b/test/Microsoft.AspNet.WebApi.Versioning.ApiExplorer.Tests/Description/ApiVersionParameterDescriptionContextTest.cs @@ -1,13 +1,16 @@ namespace Microsoft.Web.Http.Description { using FluentAssertions; + using Microsoft.Web.Http.Versioning; using Moq; + using System.Collections.ObjectModel; using System.Linq; using System.Net.Http.Formatting; using System.Net.Http.Headers; using System.Web.Http; using System.Web.Http.Controllers; using System.Web.Http.Description; + using System.Web.Http.Filters; using Xunit; using static Microsoft.Web.Http.Versioning.ApiVersionParameterLocation; using static System.Web.Http.Description.ApiParameterSource; @@ -19,7 +22,7 @@ public void add_parameter_should_add_descriptor_for_query_parameter() { // arrange var configuration = new HttpConfiguration(); - var action = new Mock() { CallBase = true }.Object; + var action = NewActionDescriptor(); var description = new ApiDescription() { ActionDescriptor = action }; var version = new ApiVersion( 1, 0 ); var options = new ApiExplorerOptions( configuration ); @@ -54,7 +57,7 @@ public void add_parameter_should_add_descriptor_for_header() { // arrange var configuration = new HttpConfiguration(); - var action = new Mock() { CallBase = true }.Object; + var action = NewActionDescriptor(); var description = new ApiDescription() { ActionDescriptor = action }; var version = new ApiVersion( 1, 0 ); var options = new ApiExplorerOptions( configuration ); @@ -89,7 +92,7 @@ public void add_parameter_should_add_descriptor_for_path() { // arrange var configuration = new HttpConfiguration(); - var action = new Mock() { CallBase = true }.Object; + var action = NewActionDescriptor(); var description = new ApiDescription() { ActionDescriptor = action }; var version = new ApiVersion( 1, 0 ); var options = new ApiExplorerOptions( configuration ); @@ -125,7 +128,7 @@ public void add_parameter_should_remove_other_descriptors_after_path_parameter_i { // arrange var configuration = new HttpConfiguration(); - var action = new Mock() { CallBase = true }.Object; + var action = NewActionDescriptor(); var description = new ApiDescription() { ActionDescriptor = action }; var version = new ApiVersion( 1, 0 ); var options = new ApiExplorerOptions( configuration ); @@ -147,7 +150,7 @@ public void add_parameter_should_not_add_query_parameter_after_path_parameter_ha { // arrange var configuration = new HttpConfiguration(); - var action = new Mock() { CallBase = true }.Object; + var action = NewActionDescriptor(); var description = new ApiDescription() { ActionDescriptor = action }; var version = new ApiVersion( 1, 0 ); var options = new ApiExplorerOptions( configuration ); @@ -169,7 +172,7 @@ public void add_parameter_should_add_descriptor_for_media_type_parameter() { // arrange var configuration = new HttpConfiguration(); - var action = new Mock() { CallBase = true }.Object; + var action = NewActionDescriptor(); var json = new JsonMediaTypeFormatter(); var formUrlEncoded = new FormUrlEncodedMediaTypeFormatter(); @@ -214,7 +217,7 @@ public void add_parameter_should_add_optional_parameter_when_allowed() { // arrange var configuration = new HttpConfiguration(); - var action = new Mock() { CallBase = true }.Object; + var action = NewActionDescriptor(); var description = new ApiDescription() { ActionDescriptor = action }; var version = new ApiVersion( 1, 0 ); var options = new ApiExplorerOptions( configuration ); @@ -251,7 +254,7 @@ public void add_parameter_should_make_parameters_optional_after_first_parameter( { // arrange var configuration = new HttpConfiguration(); - var action = new Mock() { CallBase = true }.Object; + var action = NewActionDescriptor(); var description = new ApiDescription() { ActionDescriptor = action }; var version = new ApiVersion( 1, 0 ); var options = new ApiExplorerOptions( configuration ); @@ -267,5 +270,18 @@ public void add_parameter_should_make_parameters_optional_after_first_parameter( description.ParameterDescriptions[0].ParameterDescriptor.IsOptional.Should().BeFalse(); description.ParameterDescriptions[1].ParameterDescriptor.IsOptional.Should().BeTrue(); } + + static HttpActionDescriptor NewActionDescriptor() + { + var action = new Mock() { CallBase = true }.Object; + var controller = new Mock() { CallBase = true }; + + controller.Setup( c => c.GetCustomAttributes( It.IsAny() ) ).Returns( new Collection() ); + controller.Setup( c => c.GetCustomAttributes( It.IsAny() ) ).Returns( new Collection() ); + controller.Setup( c => c.GetFilters() ).Returns( new Collection() ); + action.ControllerDescriptor = controller.Object; + + return action; + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer.Tests/ApiVersionParameterDescriptionContextTest.cs b/test/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer.Tests/ApiVersionParameterDescriptionContextTest.cs index 247eadc0..c56ff847 100644 --- a/test/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer.Tests/ApiVersionParameterDescriptionContextTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer.Tests/ApiVersionParameterDescriptionContextTest.cs @@ -1,6 +1,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer { using FluentAssertions; + using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.AspNetCore.Mvc.Routing; @@ -18,8 +19,8 @@ public class ApiVersionParameterDescriptionContextTest public void add_parameter_should_add_descriptor_for_query_parameter() { // arrange - var description = new ApiDescription(); var version = new ApiVersion( 1, 0 ); + var description = NewApiDescription( version ); var modelMetadata = new Mock( ModelMetadataIdentity.ForType( typeof( string ) ) ); var options = new ApiExplorerOptions() { @@ -52,8 +53,8 @@ public void add_parameter_should_add_descriptor_for_query_parameter() public void add_parameter_should_add_descriptor_for_header() { // arrange - var description = new ApiDescription(); var version = new ApiVersion( 1, 0 ); + var description = NewApiDescription( version ); var modelMetadata = new Mock( ModelMetadataIdentity.ForType( typeof( string ) ) ); var options = new ApiExplorerOptions() { @@ -95,8 +96,8 @@ public void add_parameter_should_add_descriptor_for_path() }, Source = BindingSource.Path }; - var description = new ApiDescription() { ParameterDescriptions = { parameter } }; var version = new ApiVersion( 1, 0 ); + var description = NewApiDescription( version, parameter ); var modelMetadata = new Mock( ModelMetadataIdentity.ForType( typeof( string ) ) ); var options = new ApiExplorerOptions() { @@ -139,8 +140,8 @@ public void add_parameter_should_remove_other_descriptors_after_path_parameter_i }, Source = BindingSource.Path }; - var description = new ApiDescription() { ParameterDescriptions = { parameter } }; var version = new ApiVersion( 1, 0 ); + var description = NewApiDescription( version, parameter ); var modelMetadata = new Mock( ModelMetadataIdentity.ForType( typeof( string ) ) ); var options = new ApiExplorerOptions() { @@ -186,8 +187,8 @@ public void add_parameter_should_not_add_query_parameter_after_path_parameter_ha }, Source = BindingSource.Path }; - var description = new ApiDescription() { ParameterDescriptions = { parameter } }; var version = new ApiVersion( 1, 0 ); + var description = NewApiDescription( version, parameter ); var modelMetadata = new Mock( ModelMetadataIdentity.ForType( typeof( string ) ) ); var options = new ApiExplorerOptions() { @@ -211,24 +212,25 @@ public void add_parameter_should_add_descriptor_for_media_type_parameter() { // arrange const string Json = "application/json"; + var version = new ApiVersion( 1, 0 ); var description = new ApiDescription() { + ActionDescriptor = new ActionDescriptor() { Properties = { [typeof( ApiVersionModel )] = new ApiVersionModel( version ) } }, SupportedRequestFormats = { - new ApiRequestFormat() { MediaType = Json } + new ApiRequestFormat() { MediaType = Json } }, SupportedResponseTypes = { - new ApiResponseType() - { - ApiResponseFormats = + new ApiResponseType() + { + ApiResponseFormats = { new ApiResponseFormat() { MediaType = Json } } - } + } } }; - var version = new ApiVersion( 1, 0 ); var modelMetadata = new Mock( ModelMetadataIdentity.ForType( typeof( string ) ) ); var options = new ApiExplorerOptions() { @@ -249,8 +251,8 @@ public void add_parameter_should_add_descriptor_for_media_type_parameter() public void add_parameter_should_add_optional_parameter_when_allowed() { // arrange - var description = new ApiDescription(); var version = new ApiVersion( 1, 0 ); + var description = NewApiDescription( version ); var modelMetadata = new Mock( ModelMetadataIdentity.ForType( typeof( string ) ) ); var options = new ApiExplorerOptions() { @@ -284,8 +286,8 @@ public void add_parameter_should_add_optional_parameter_when_allowed() public void add_parameter_should_make_parameters_optional_after_first_parameter() { // arrange - var description = new ApiDescription(); var version = new ApiVersion( 1, 0 ); + var description = NewApiDescription( version ); var modelMetadata = new Mock( ModelMetadataIdentity.ForType( typeof( string ) ) ); var options = new ApiExplorerOptions() { @@ -302,5 +304,21 @@ public void add_parameter_should_make_parameters_optional_after_first_parameter( description.ParameterDescriptions[0].RouteInfo.IsOptional.Should().BeFalse(); description.ParameterDescriptions[1].RouteInfo.IsOptional.Should().BeTrue(); } + + static ApiDescription NewApiDescription( ApiVersion apiVersion, params ApiParameterDescription[] parameters ) + { + var description = new ApiDescription(); + var action = new ActionDescriptor(); + + action.SetProperty( new ApiVersionModel( apiVersion ) ); + description.ActionDescriptor = action; + + foreach ( var parameter in parameters ) + { + description.ParameterDescriptions.Add( parameter ); + } + + return description; + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.OData.Versioning.ApiExplorer.Tests/AspNet.OData/Company.cs b/test/Microsoft.AspNetCore.OData.Versioning.ApiExplorer.Tests/AspNet.OData/Company.cs new file mode 100644 index 00000000..2b696bd5 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Versioning.ApiExplorer.Tests/AspNet.OData/Company.cs @@ -0,0 +1,18 @@ +namespace Microsoft.AspNet.OData +{ + using System; + using System.Collections.Generic; + + public class Company + { + public int CompanyId { get; set; } + + public Company ParentCompany { get; set; } + + public List Subsidiaries { get; set; } + + public string Name { get; set; } + + public DateTime DateFounded { get; set; } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.OData.Versioning.ApiExplorer.Tests/AspNet.OData/DefaultModelTypeBuilderTest.cs b/test/Microsoft.AspNetCore.OData.Versioning.ApiExplorer.Tests/AspNet.OData/DefaultModelTypeBuilderTest.cs index 47c51d8e..317ab314 100644 --- a/test/Microsoft.AspNetCore.OData.Versioning.ApiExplorer.Tests/AspNet.OData/DefaultModelTypeBuilderTest.cs +++ b/test/Microsoft.AspNetCore.OData.Versioning.ApiExplorer.Tests/AspNet.OData/DefaultModelTypeBuilderTest.cs @@ -31,6 +31,51 @@ public void substituted_type_should_be_same_as_original_type( Type originalType substitutedType.Should().Be( originalType ); } + [Fact] + public void substituted_type_should_be_extracted_from_parent_generic() + { + // arrange + var modelBuilder = new ODataConventionModelBuilder(); + + modelBuilder.EntitySet( "Contacts" ); + modelBuilder.EntityType
(); + + var context = NewContext( modelBuilder.GetEdmModel() ); + var originalType = typeof( Delta ); + + // act + var substitutedType = originalType.SubstituteIfNecessary( context ); + + // assert + substitutedType.Should().Be( typeof( Contact ) ); + } + + [Fact] + public void type_should_be_match_edm_when_extracted_and_substituted_from_parent_generic() + { + // arrange + var modelBuilder = new ODataConventionModelBuilder(); + var contact = modelBuilder.EntitySet( "Contacts" ).EntityType; + + contact.Ignore( p => p.Email ); + contact.Ignore( p => p.Phone ); + contact.Ignore( p => p.Addresses ); + + var context = NewContext( modelBuilder.GetEdmModel() ); + var originalType = typeof( Delta ); + + // act + var substitutedType = originalType.SubstituteIfNecessary( context ); + + // assert + substitutedType.Should().NotBe( originalType ); + substitutedType.Should().NotBe( typeof( Contact ) ); + substitutedType.GetRuntimeProperties().Should().HaveCount( 3 ); + substitutedType.Should().HaveProperty( nameof( Contact.ContactId ) ); + substitutedType.Should().HaveProperty( nameof( Contact.FirstName ) ); + substitutedType.Should().HaveProperty( nameof( Contact.LastName ) ); + } + [Theory] [MemberData( nameof( SubstitutionData ) )] public void type_should_match_edm_with_top_entity_substitution( Type originalType ) @@ -87,6 +132,47 @@ public void type_should_match_edm_with_nested_entity_substitution() innerType.Should().HaveProperty( nameof( Contact.LastName ) ); } + [Fact] + public void type_should_match_with_self_referencing_property_substitution() + { + // arrange + var modelBuilder = new ODataConventionModelBuilder(); + + modelBuilder.EntitySet( "Companies" ); + + var context = NewContext( modelBuilder.GetEdmModel() ); + var originalType = typeof( Company ); + + //act + var subsitutedType = originalType.SubstituteIfNecessary( context ); + + // assert + subsitutedType.Should().Be( typeof( Company ) ); + } + + [Fact] + public void type_should_use_self_referencing_property_substitution() + { + // arrange + var modelBuilder = new ODataConventionModelBuilder(); + var company = modelBuilder.EntitySet( "Companies" ).EntityType; + + company.Ignore( c => c.DateFounded ); + + var context = NewContext( modelBuilder.GetEdmModel() ); + var originalType = typeof( Company ); + + //act + var subsitutedType = originalType.SubstituteIfNecessary( context ); + + // assert + subsitutedType.GetRuntimeProperties().Should().HaveCount( 4 ); + subsitutedType.Should().HaveProperty( nameof( Company.CompanyId ) ); + subsitutedType.Should().HaveProperty( nameof( Company.Name ) ); + subsitutedType.Should().Be( subsitutedType.GetRuntimeProperty( nameof( Company.ParentCompany ) ).PropertyType ); + subsitutedType.Should().Be( subsitutedType.GetRuntimeProperty( nameof( Company.Subsidiaries ) ).PropertyType.GetGenericArguments()[0] ); + } + [Theory] [MemberData( nameof( SubstitutionData ) )] public void type_should_match_edm_with_child_entity_substitution( Type originalType ) @@ -113,7 +199,7 @@ public void type_should_match_edm_with_child_entity_substitution( Type originalT nextType.Should().HaveProperty( nameof( Contact.LastName ) ); nextType.Should().HaveProperty( nameof( Contact.Email ) ); nextType.Should().HaveProperty( nameof( Contact.Phone ) ); - nextType = nextType.GetRuntimeProperties().Single( p => p.Name == nameof( Contact.Addresses ) ).PropertyType.GetGenericArguments()[0]; + nextType = nextType.GetRuntimeProperty( nameof( Contact.Addresses ) ).PropertyType.GetGenericArguments()[0]; nextType.GetRuntimeProperties().Should().HaveCount( 5 ); nextType.Should().HaveProperty( nameof( Address.AddressId ) ); nextType.Should().HaveProperty( nameof( Address.Street ) ); @@ -156,7 +242,6 @@ public static IEnumerable SubstitutionNotRequiredData yield return new object[] { typeof( IEnumerable ) }; yield return new object[] { typeof( IEnumerable ) }; yield return new object[] { typeof( ODataValue> ) }; - yield return new object[] { typeof( Delta ) }; } } @@ -166,7 +251,6 @@ public static IEnumerable SubstitutionData { yield return new object[] { typeof( IEnumerable ) }; yield return new object[] { typeof( ODataValue ) }; - yield return new object[] { typeof( Delta ) }; } }