diff --git a/examples/AspNetCore/OData/ODataOpenApiExample/ConfigureSwaggerOptions.cs b/examples/AspNetCore/OData/ODataOpenApiExample/ConfigureSwaggerOptions.cs index 5384c160..f3b2bf12 100644 --- a/examples/AspNetCore/OData/ODataOpenApiExample/ConfigureSwaggerOptions.cs +++ b/examples/AspNetCore/OData/ODataOpenApiExample/ConfigureSwaggerOptions.cs @@ -50,25 +50,84 @@ private static OpenApiInfo CreateInfoForApiVersion( ApiVersionDescription descri text.Append( " This API version has been deprecated." ); } - if ( description.SunsetPolicy is { } policy ) + if ( description.DeprecationPolicy is { } deprecationPolicy ) { - if ( policy.Date is { } when ) + if ( deprecationPolicy.Date is { } when ) { - text.Append( " The API will be sunset on " ) - .Append( when.Date.ToShortDateString() ) - .Append( '.' ); + if ( when < DateTime.Now ) + { + text.Append( " The API has been deprecated on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } + else + { + text.Append( " The API will be deprecated on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } } - if ( policy.HasLinks ) + if ( deprecationPolicy.HasLinks ) { text.AppendLine(); var rendered = false; - for ( var i = 0; i < policy.Links.Count; i++ ) + foreach ( var link in deprecationPolicy.Links ) { - var link = policy.Links[i]; + if ( link.Type == "text/html" ) + { + if ( !rendered ) + { + text.Append( "

Links

" ); + } + } + } + + if ( description.SunsetPolicy is { } sunsetPolicy ) + { + if ( sunsetPolicy.Date is { } when ) + { + if ( when < DateTime.Now ) + { + text.Append( " The API has been sunset on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } + else + { + text.Append( " The API will be sunset on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } + } + + if ( sunsetPolicy.HasLinks ) + { + text.AppendLine(); + + var rendered = false; + + foreach ( var link in sunsetPolicy.Links ) + { if ( link.Type == "text/html" ) { if ( !rendered ) diff --git a/examples/AspNetCore/OData/SomeODataOpenApiExample/ConfigureSwaggerOptions.cs b/examples/AspNetCore/OData/SomeODataOpenApiExample/ConfigureSwaggerOptions.cs index b725ab72..38a250af 100644 --- a/examples/AspNetCore/OData/SomeODataOpenApiExample/ConfigureSwaggerOptions.cs +++ b/examples/AspNetCore/OData/SomeODataOpenApiExample/ConfigureSwaggerOptions.cs @@ -50,25 +50,84 @@ private static OpenApiInfo CreateInfoForApiVersion( ApiVersionDescription descri text.Append( " This API version has been deprecated." ); } - if ( description.SunsetPolicy is { } policy ) + if ( description.DeprecationPolicy is { } deprecationPolicy ) { - if ( policy.Date is { } when ) + if ( deprecationPolicy.Date is { } when ) { - text.Append( " The API will be sunset on " ) - .Append( when.Date.ToShortDateString() ) - .Append( '.' ); + if ( when < DateTime.Now ) + { + text.Append( " The API has been deprecated on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } + else + { + text.Append( " The API will be deprecated on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } } - if ( policy.HasLinks ) + if ( deprecationPolicy.HasLinks ) { text.AppendLine(); var rendered = false; - for ( var i = 0; i < policy.Links.Count; i++ ) + foreach ( var link in deprecationPolicy.Links ) { - var link = policy.Links[i]; + if ( link.Type == "text/html" ) + { + if ( !rendered ) + { + text.Append( "

Links

" ); + } + } + } + + if ( description.SunsetPolicy is { } sunsetPolicy ) + { + if ( sunsetPolicy.Date is { } when ) + { + if ( when < DateTime.Now ) + { + text.Append( " The API has been sunset on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } + else + { + text.Append( " The API will be sunset on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } + } + + if ( sunsetPolicy.HasLinks ) + { + text.AppendLine(); + + var rendered = false; + + foreach ( var link in sunsetPolicy.Links ) + { if ( link.Type == "text/html" ) { if ( !rendered ) diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/ConfigureSwaggerOptions.cs b/examples/AspNetCore/WebApi/MinimalOpenApiExample/ConfigureSwaggerOptions.cs index d531cea4..5a8cbf51 100644 --- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/ConfigureSwaggerOptions.cs +++ b/examples/AspNetCore/WebApi/MinimalOpenApiExample/ConfigureSwaggerOptions.cs @@ -50,25 +50,84 @@ private static OpenApiInfo CreateInfoForApiVersion( ApiVersionDescription descri text.Append( " This API version has been deprecated." ); } - if ( description.SunsetPolicy is { } policy ) + if ( description.DeprecationPolicy is { } deprecationPolicy ) { - if ( policy.Date is { } when ) + if ( deprecationPolicy.Date is { } when ) { - text.Append( " The API will be sunset on " ) - .Append( when.Date.ToShortDateString() ) - .Append( '.' ); + if ( when < DateTime.Now ) + { + text.Append( " The API has been deprecated on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } + else + { + text.Append( " The API will be deprecated on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } } - if ( policy.HasLinks ) + if ( deprecationPolicy.HasLinks ) { text.AppendLine(); var rendered = false; - for ( var i = 0; i < policy.Links.Count; i++ ) + foreach ( var link in deprecationPolicy.Links ) { - var link = policy.Links[i]; + if ( link.Type == "text/html" ) + { + if ( !rendered ) + { + text.Append( "

Links

" ); + } + } + } + + if ( description.SunsetPolicy is { } sunsetPolicy ) + { + if ( sunsetPolicy.Date is { } when ) + { + if ( when < DateTime.Now ) + { + text.Append( " The API has been sunset on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } + else + { + text.Append( " The API will be sunset on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } + } + + if ( sunsetPolicy.HasLinks ) + { + text.AppendLine(); + + var rendered = false; + + foreach ( var link in sunsetPolicy.Links ) + { if ( link.Type == "text/html" ) { if ( !rendered ) diff --git a/examples/AspNetCore/WebApi/OpenApiExample/ConfigureSwaggerOptions.cs b/examples/AspNetCore/WebApi/OpenApiExample/ConfigureSwaggerOptions.cs index d531cea4..5a8cbf51 100644 --- a/examples/AspNetCore/WebApi/OpenApiExample/ConfigureSwaggerOptions.cs +++ b/examples/AspNetCore/WebApi/OpenApiExample/ConfigureSwaggerOptions.cs @@ -50,25 +50,84 @@ private static OpenApiInfo CreateInfoForApiVersion( ApiVersionDescription descri text.Append( " This API version has been deprecated." ); } - if ( description.SunsetPolicy is { } policy ) + if ( description.DeprecationPolicy is { } deprecationPolicy ) { - if ( policy.Date is { } when ) + if ( deprecationPolicy.Date is { } when ) { - text.Append( " The API will be sunset on " ) - .Append( when.Date.ToShortDateString() ) - .Append( '.' ); + if ( when < DateTime.Now ) + { + text.Append( " The API has been deprecated on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } + else + { + text.Append( " The API will be deprecated on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } } - if ( policy.HasLinks ) + if ( deprecationPolicy.HasLinks ) { text.AppendLine(); var rendered = false; - for ( var i = 0; i < policy.Links.Count; i++ ) + foreach ( var link in deprecationPolicy.Links ) { - var link = policy.Links[i]; + if ( link.Type == "text/html" ) + { + if ( !rendered ) + { + text.Append( "

Links

" ); + } + } + } + + if ( description.SunsetPolicy is { } sunsetPolicy ) + { + if ( sunsetPolicy.Date is { } when ) + { + if ( when < DateTime.Now ) + { + text.Append( " The API has been sunset on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } + else + { + text.Append( " The API will be sunset on " ) + .Append( when.Date.ToShortDateString() ) + .Append( '.' ); + } + } + + if ( sunsetPolicy.HasLinks ) + { + text.AppendLine(); + + var rendered = false; + + foreach ( var link in sunsetPolicy.Links ) + { if ( link.Type == "text/html" ) { if ( !rendered ) diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/DeprecationPolicy.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/DeprecationPolicy.cs new file mode 100644 index 00000000..8c454289 --- /dev/null +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/DeprecationPolicy.cs @@ -0,0 +1,78 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// +/// Represents an API version deprecation policy. +/// +public class DeprecationPolicy +{ + private LinkList? links; + + /// + /// Gets a read-only list of links that provide information about the deprecation policy. + /// + /// A read-only list of HTTP links. + /// If a link is provided, generally only one link is necessary; however, additional + /// links might be provided for different languages or different formats such as a HTML page + /// or a JSON file. + public IList Links => links ??= new( "deprecation" ); + + /// + /// Gets a value indicating whether the deprecation policy has any associated links. + /// + /// True if the deprecation policy has associated links; otherwise, false. + public bool HasLinks => links is not null && links.Count > 0; + + /// + /// Gets the date and time when the API version will be deprecated. + /// + /// The date and time when the API version will be deprecated, if any. + public DateTimeOffset? Date { get; } + + /// + /// Initializes a new instance of the class. + /// + public DeprecationPolicy() { } + + /// + /// Initializes a new instance of the class. + /// + /// The date and time when the API version will be deprecated. + /// The optional link which provides information about the deprecation policy. + public DeprecationPolicy( DateTimeOffset date, LinkHeaderValue? link = default ) + { + Date = date; + + if ( link is not null ) + { + Links.Add( link ); + } + } + + /// + /// Initializes a new instance of the class. + /// + /// The link which provides information about the deprecation policy. + public DeprecationPolicy( LinkHeaderValue link ) => Links.Add( link ); + + /// + /// Returns a boolean to indicate if this policy is effective at the given . + /// + /// The point in time to serve as a reference. + /// A boolean which indicates if this policy is effective. + public bool IsEffective( DateTimeOffset? dateTimeOffset ) + { + if ( dateTimeOffset is not { } when ) + { + return true; + } + + if ( Date is not { } date ) + { + return true; + } + + return date <= when; + } +} \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/Format.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/Format.cs index dbea4469..2838dbd8 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/Format.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/Format.cs @@ -11,8 +11,10 @@ internal static class Format #if NETSTANDARD internal static readonly string ApiVersionBadStatus = SR.ApiVersionBadStatus; internal static readonly string ApiVersionBadGroupVersion = SR.ApiVersionBadGroupVersion; + internal static readonly string InvalidRelationType = SR.InvalidRelationType; #else internal static readonly CompositeFormat ApiVersionBadStatus = CompositeFormat.Parse( SR.ApiVersionBadStatus ); internal static readonly CompositeFormat ApiVersionBadGroupVersion = CompositeFormat.Parse( SR.ApiVersionBadGroupVersion ); + internal static readonly CompositeFormat InvalidRelationType = CompositeFormat.Parse( SR.InvalidRelationType ); #endif } \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IApiVersioningPolicyBuilder.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IApiVersioningPolicyBuilder.cs index 7a692d84..f400ec49 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/IApiVersioningPolicyBuilder.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IApiVersioningPolicyBuilder.cs @@ -23,4 +23,14 @@ public interface IApiVersioningPolicyBuilder /// The and /// parameters are both null. ISunsetPolicyBuilder Sunset( string? name, ApiVersion? apiVersion ); + + /// + /// Creates and returns a new deprecation policy builder. + /// + /// The optional name of the API the policy is for. + /// The optional API version the policy is for. + /// A new deprecation policy builder. + /// The and + /// parameters are both null. + public IDeprecationPolicyBuilder Deprecate( string? name, ApiVersion? apiVersion ); } \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IApiVersioningPolicyBuilderExtensions.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IApiVersioningPolicyBuilderExtensions.cs index 048d6d49..afdfd5b9 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/IApiVersioningPolicyBuilderExtensions.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IApiVersioningPolicyBuilderExtensions.cs @@ -157,4 +157,151 @@ public static ISunsetPolicyBuilder Sunset( ArgumentNullException.ThrowIfNull( builder ); return builder.Sunset( default, new ApiVersion( groupVersion, status ) ); } + + /// + /// Creates and returns a new deprecation policy builder. + /// + /// The extended API versioning policy builder. + /// The name of the API the policy is for. + /// A new deprecation policy builder. + public static IDeprecationPolicyBuilder Deprecate( this IApiVersioningPolicyBuilder builder, string name ) + { + ArgumentNullException.ThrowIfNull( builder ); + return builder.Deprecate( name, default ); + } + + /// + /// Creates and returns a new deprecation policy builder. + /// + /// The extended API versioning policy builder. + /// The name of the API the policy is for. + /// The major version number. + /// The optional minor version number. + /// The optional version status. + /// A new deprecation policy builder. + public static IDeprecationPolicyBuilder Deprecate( + this IApiVersioningPolicyBuilder builder, + string name, + int majorVersion, + int? minorVersion = default, + string? status = default ) + { + ArgumentNullException.ThrowIfNull( builder ); + return builder.Deprecate( name, new ApiVersion( majorVersion, minorVersion, status ) ); + } + + /// + /// Creates and returns a new deprecation policy builder. + /// + /// The extended API versioning policy builder. + /// The name of the API the policy is for. + /// The version number. + /// The optional version status. + /// A new deprecation policy builder. + public static IDeprecationPolicyBuilder Deprecate( this IApiVersioningPolicyBuilder builder, string name, double version, string? status = default ) + { + ArgumentNullException.ThrowIfNull( builder ); + return builder.Deprecate( name, new ApiVersion( version, status ) ); + } + + /// + /// Creates and returns a new deprecation policy builder. + /// + /// The extended API versioning policy builder. + /// The name of the API the policy is for. + /// The version year. + /// The version month. + /// The version day. + /// The optional version status. + /// A new deprecation policy builder. + public static IDeprecationPolicyBuilder Deprecate( this IApiVersioningPolicyBuilder builder, string name, int year, int month, int day, string? status = default ) + { + ArgumentNullException.ThrowIfNull( builder ); + return builder.Deprecate( name, new ApiVersion( new DateOnly( year, month, day ), status ) ); + } + + /// + /// Creates and returns a new deprecation policy builder. + /// + /// The extended API versioning policy builder. + /// The name of the API the policy is for. + /// The group version. + /// The optional version status. + /// A new deprecation policy builder. + public static IDeprecationPolicyBuilder Deprecate( this IApiVersioningPolicyBuilder builder, string name, DateOnly groupVersion, string? status = default ) + { + ArgumentNullException.ThrowIfNull( builder ); + return builder.Deprecate( name, new ApiVersion( groupVersion, status ) ); + } + + /// + /// Creates and returns a new deprecation policy builder. + /// + /// The extended API versioning policy builder. + /// The API version the policy is for. + /// A new deprecation policy builder. + public static IDeprecationPolicyBuilder Deprecate( this IApiVersioningPolicyBuilder builder, ApiVersion apiVersion ) + { + ArgumentNullException.ThrowIfNull( builder ); + return builder.Deprecate( default, apiVersion ); + } + + /// + /// Creates and returns a new deprecation policy builder. + /// + /// The extended API versioning policy builder. + /// The major version number. + /// The optional minor version number. + /// The optional version status. + /// A new deprecation policy builder. + public static IDeprecationPolicyBuilder Deprecate( + this IApiVersioningPolicyBuilder builder, + int majorVersion, + int? minorVersion = default, + string? status = default ) + { + ArgumentNullException.ThrowIfNull( builder ); + return builder.Deprecate( default, new ApiVersion( majorVersion, minorVersion, status ) ); + } + + /// + /// Creates and returns a new deprecation policy builder. + /// + /// The extended API versioning policy builder. + /// The version number. + /// The optional version status. + /// A new deprecation policy builder. + public static IDeprecationPolicyBuilder Deprecate( this IApiVersioningPolicyBuilder builder, double version, string? status = default ) + { + ArgumentNullException.ThrowIfNull( builder ); + return builder.Deprecate( default, new ApiVersion( version, status ) ); + } + + /// + /// Creates and returns a new deprecation policy builder. + /// + /// The extended API versioning policy builder. + /// The version year. + /// The version month. + /// The version day. + /// The optional version status. + /// A new deprecation policy builder. + public static IDeprecationPolicyBuilder Deprecate( this IApiVersioningPolicyBuilder builder, int year, int month, int day, string? status = default ) + { + ArgumentNullException.ThrowIfNull( builder ); + return builder.Deprecate( default, new ApiVersion( new DateOnly( year, month, day ), status ) ); + } + + /// + /// Creates and returns a new deprecation policy builder. + /// + /// The extended API versioning policy builder. + /// The group version. + /// The optional version status. + /// A new deprecation policy builder. + public static IDeprecationPolicyBuilder Deprecate( this IApiVersioningPolicyBuilder builder, DateOnly groupVersion, string? status = default ) + { + ArgumentNullException.ThrowIfNull( builder ); + return builder.Deprecate( default, new ApiVersion( groupVersion, status ) ); + } } \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IDeprecationPolicyBuilder.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IDeprecationPolicyBuilder.cs new file mode 100644 index 00000000..8f95e51a --- /dev/null +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IDeprecationPolicyBuilder.cs @@ -0,0 +1,8 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// +/// Defines the behavior of a deprecation policy builder. +/// +public interface IDeprecationPolicyBuilder : IPolicyBuilder, IPolicyWithLink, IPolicyWithEffectiveDate { } \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyBuilder.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyBuilder.cs new file mode 100644 index 00000000..fde4520b --- /dev/null +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyBuilder.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// +/// Defines the behavior of a policy builder which applies to a single API version. +/// +/// The type of policy which is built by this builder. +public interface IPolicyBuilder +{ + /// + /// Gets the policy name. + /// + /// The policy name, if any. + /// The name is typically of an API. + string? Name { get; } + + /// + /// Gets the API version the policy is for. + /// + /// The specific policy API version, if any. + ApiVersion? ApiVersion { get; } + + /// + /// Configures the builder per the specified . + /// + /// The applied policy. + void Per( TPolicy policy ); + + /// + /// Builds and returns a policy. + /// + /// A new policy. + TPolicy Build(); +} \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyBuilderExtensions.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyBuilderExtensions.cs new file mode 100644 index 00000000..08142139 --- /dev/null +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyBuilderExtensions.cs @@ -0,0 +1,52 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// +/// Provides extension methods for the interface. +/// +public static class IPolicyBuilderExtensions +{ + /// + /// Creates and returns a new link builder. + /// + /// The extended policy builder. + /// The link target URL. + /// A new link builder. + public static ILinkBuilder Link( this IPolicyWithLink builder, string linkTarget ) + { + ArgumentNullException.ThrowIfNull( builder ); + return builder.Link( new Uri( linkTarget, UriKind.RelativeOrAbsolute ) ); + } + + /// + /// Indicates when a policy is applied. + /// + /// The type of policy builder. + /// The extended policy builder. + /// The time when the policy is applied. + /// The current policy builder. + public static TBuilder Effective( this TBuilder builder, DateTimeOffset effectiveDate ) + where TBuilder : notnull, IPolicyWithEffectiveDate + { + ArgumentNullException.ThrowIfNull( builder ); + builder.SetEffectiveDate( effectiveDate ); + return builder; + } + + /// + /// Indicates when a policy is applied. + /// + /// The type of policy builder. + /// The extended policy builder. + /// The year when the policy is applied. + /// The month when the policy is applied. + /// The day when the policy is applied. + /// The current policy builder. + public static TBuilder Effective( this TBuilder builder, int year, int month, int day ) + where TBuilder : notnull, IPolicyWithEffectiveDate + { + ArgumentNullException.ThrowIfNull( builder ); + return builder.Effective( new DateTimeOffset( new DateTime( year, month, day ) ) ); + } +} \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyManager.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyManager.cs similarity index 51% rename from src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyManager.cs rename to src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyManager.cs index de2dd77e..43a5fd8d 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyManager.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyManager.cs @@ -3,20 +3,21 @@ namespace Asp.Versioning; /// -/// Defines the behavior of an API version sunset policy manager. +/// Defines the behavior of an API version policy manager. /// -public interface ISunsetPolicyManager +/// The type of the policy. +public interface IPolicyManager { /// - /// Returns the sunset policy for the specified API and version. + /// Returns the policy for the specified API and version. /// /// The name of the API. /// The API version to get the policy for. - /// The applicable sunset policy, if any. - /// True if the sunset policy was retrieved; otherwise, false. - /// If is null, it is assumed the caller intends to match any sunset + /// The applicable policy, if any. + /// True if the policy was retrieved; otherwise, false. + /// If is null, it is assumed the caller intends to match any /// policy for the specified API version. If /// API version is null, it is assumed the caller intends to match - /// any sunset policy for the specified . - bool TryGetPolicy( string? name, ApiVersion? apiVersion, [MaybeNullWhen( false )] out SunsetPolicy sunsetPolicy ); + /// any policy for the specified . + bool TryGetPolicy( string? name, ApiVersion? apiVersion, [MaybeNullWhen( false )] out TPolicy policy ); } \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyManagerExtensions.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyManagerExtensions.cs new file mode 100644 index 00000000..0c468f71 --- /dev/null +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyManagerExtensions.cs @@ -0,0 +1,118 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// +/// Provides extension methods for the interface. +/// +public static class IPolicyManagerExtensions +{ + /// + /// Returns the policy for the specified API and version. + /// + /// The extended policy manager. + /// The API version to get the policy for. + /// The applicable policy, if any. + /// The type of policy. + /// True if the policy was retrieved; otherwise, false. + public static bool TryGetPolicy( + this IPolicyManager policyManager, + ApiVersion apiVersion, + [MaybeNullWhen( false )] out TPolicy policy ) + { + ArgumentNullException.ThrowIfNull( policyManager ); + return policyManager.TryGetPolicy( default, apiVersion, out policy ); + } + + /// + /// Returns the policy for the specified API and version. + /// + /// The extended policy manager. + /// The name of the API. + /// The applicable policy, if any. + /// The type of policy. + /// True if the policy was retrieved; otherwise, false. + public static bool TryGetPolicy( + this IPolicyManager policyManager, + string name, + [MaybeNullWhen( false )] out TPolicy policy ) + { + ArgumentNullException.ThrowIfNull( policyManager ); + return policyManager.TryGetPolicy( name, default, out policy ); + } + + /// + /// Attempts to resolve a policy for the specified name and API version combination. + /// + /// The extended policy manager. + /// The name of the API. + /// The API version to get the policy for. + /// The type of policy. + /// The applicable policy, if any. + /// The resolution order is as follows: + /// + /// and + /// only + /// only + /// + /// + public static TPolicy? ResolvePolicyOrDefault( + this IPolicyManager policyManager, + string? name, + ApiVersion? apiVersion ) + { + ArgumentNullException.ThrowIfNull( policyManager ); + + if ( policyManager.TryResolvePolicy( name, apiVersion, out var policy ) ) + { + return policy; + } + + return default; + } + + /// + /// Attempts to resolve a policy for the specified name and API version combination. + /// + /// The extended policy manager. + /// The name of the API. + /// The API version to get the policy for. + /// The applicable policy, if any. + /// The type of policy. + /// True if the policy was retrieved; otherwise, false. + /// The resolution order is as follows: + /// + /// and + /// only + /// only + /// + /// + public static bool TryResolvePolicy( + this IPolicyManager policyManager, + string? name, + ApiVersion? apiVersion, + [MaybeNullWhen( false )] out TPolicy policy ) + { + ArgumentNullException.ThrowIfNull( policyManager ); + + if ( !string.IsNullOrEmpty( name ) ) + { + if ( apiVersion != null && policyManager.TryGetPolicy( name, apiVersion, out policy ) ) + { + return true; + } + else if ( policyManager.TryGetPolicy( name!, out policy ) ) + { + return true; + } + } + + if ( apiVersion != null && policyManager.TryGetPolicy( apiVersion, out policy ) ) + { + return true; + } + + policy = default!; + return false; + } +} \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyWithEffectiveDate.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyWithEffectiveDate.cs new file mode 100644 index 00000000..f148a0c3 --- /dev/null +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyWithEffectiveDate.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// +/// A policy which can be configured to only be effective after a particular date. +/// +public interface IPolicyWithEffectiveDate +{ + /// + /// Indicates when a policy is applied. + /// + /// + /// The date and time when a policy is applied. + /// + void SetEffectiveDate( DateTimeOffset effectiveDate ); +} \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyWithLink.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyWithLink.cs new file mode 100644 index 00000000..2e4bd315 --- /dev/null +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/IPolicyWithLink.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// +/// Defines a policy which can (optionally) expose a link to more information. +/// +public interface IPolicyWithLink +{ + /// + /// Creates and returns a new link builder. + /// + /// The link target URL. + /// A new link builder. + ILinkBuilder Link( Uri linkTarget ); +} \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyBuilder.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyBuilder.cs index deca62dc..58a45e70 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyBuilder.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyBuilder.cs @@ -5,45 +5,4 @@ namespace Asp.Versioning; /// /// Defines the behavior of a sunset policy builder. /// -public interface ISunsetPolicyBuilder -{ - /// - /// Gets the policy name. - /// - /// The policy name, if any. - /// The name is typically of an API. - string? Name { get; } - - /// - /// Gets the API version the policy is for. - /// - /// The specific policy API version, if any. - ApiVersion? ApiVersion { get; } - - /// - /// Applies a sunset policy per the specified policy. - /// - /// The applied sunset policy. - void Per( SunsetPolicy policy ); - - /// - /// Indicates when a sunset policy is applied. - /// - /// The date and time when a - /// sunset policy is applied. - /// The current sunset policy builder. - ISunsetPolicyBuilder Effective( DateTimeOffset sunsetDate ); - - /// - /// Creates and returns a new link builder. - /// - /// The link target URL. - /// A new link builder. - ILinkBuilder Link( Uri linkTarget ); - - /// - /// Builds and returns a sunset policy. - /// - /// A new sunset policy. - SunsetPolicy Build(); -} \ No newline at end of file +public interface ISunsetPolicyBuilder : IPolicyBuilder, IPolicyWithLink, IPolicyWithEffectiveDate { } \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyBuilderExtensions.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyBuilderExtensions.cs deleted file mode 100644 index 4a81ebd4..00000000 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyBuilderExtensions.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning; - -/// -/// Provides extension methods for the interface. -/// -public static class ISunsetPolicyBuilderExtensions -{ - /// - /// Creates and returns a new link builder. - /// - /// The extended sunset policy builder. - /// The link target URL. - /// A new link builder. - public static ILinkBuilder Link( this ISunsetPolicyBuilder builder, string linkTarget ) - { - ArgumentNullException.ThrowIfNull( builder ); - return builder.Link( new Uri( linkTarget, UriKind.RelativeOrAbsolute ) ); - } - - /// - /// Indicates when a sunset policy is applied. - /// - /// The type of sunset policy builder. - /// The extended sunset policy builder. - /// The year when the sunset policy is applied. - /// The month when the sunset policy is applied. - /// The day when the sunset policy is applied. - /// The current sunset policy builder. - public static TBuilder Effective( this TBuilder builder, int year, int month, int day ) - where TBuilder : notnull, ISunsetPolicyBuilder - { - ArgumentNullException.ThrowIfNull( builder ); - builder.Effective( new DateTimeOffset( new DateTime( year, month, day ) ) ); - return builder; - } -} \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyManagerExtensions.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyManagerExtensions.cs deleted file mode 100644 index f28f9cca..00000000 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/ISunsetPolicyManagerExtensions.cs +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning; - -/// -/// Provides extension methods for the interface. -/// -public static class ISunsetPolicyManagerExtensions -{ - /// - /// Returns the sunset policy for the specified API and version. - /// - /// The extended sunset policy manager. - /// The API version to get the policy for. - /// The applicable sunset policy, if any. - /// True if the sunset policy was retrieved; otherwise, false. - public static bool TryGetPolicy( - this ISunsetPolicyManager policyManager, - ApiVersion apiVersion, - [MaybeNullWhen( false )] out SunsetPolicy sunsetPolicy ) - { - ArgumentNullException.ThrowIfNull( policyManager ); - return policyManager.TryGetPolicy( default, apiVersion, out sunsetPolicy ); - } - - /// - /// Returns the sunset policy for the specified API and version. - /// - /// The extended sunset policy manager. - /// The name of the API. - /// The applicable sunset policy, if any. - /// True if the sunset policy was retrieved; otherwise, false. - public static bool TryGetPolicy( - this ISunsetPolicyManager policyManager, - string name, - [MaybeNullWhen( false )] out SunsetPolicy sunsetPolicy ) - { - ArgumentNullException.ThrowIfNull( policyManager ); - return policyManager.TryGetPolicy( name, default, out sunsetPolicy ); - } - - /// - /// Attempts to resolve a sunset policy for the specified name and API version combination. - /// - /// The extended sunset policy manager. - /// The name of the API. - /// The API version to get the policy for. - /// The applicable sunset policy, if any. - /// The resolution order is as follows: - /// - /// and - /// only - /// only - /// - /// - public static SunsetPolicy? ResolvePolicyOrDefault( - this ISunsetPolicyManager policyManager, - string? name, - ApiVersion? apiVersion ) - { - ArgumentNullException.ThrowIfNull( policyManager ); - - if ( policyManager.TryResolvePolicy( name, apiVersion, out var policy ) ) - { - return policy; - } - - return default; - } - - /// - /// Attempts to resolve a sunset policy for the specified name and API version combination. - /// - /// The extended sunset policy manager. - /// The name of the API. - /// The API version to get the policy for. - /// /// The applicable sunset policy, if any. - /// True if the sunset policy was retrieved; otherwise, false. - /// The resolution order is as follows: - /// - /// and - /// only - /// only - /// - /// - public static bool TryResolvePolicy( - this ISunsetPolicyManager policyManager, - string? name, - ApiVersion? apiVersion, - [MaybeNullWhen( false )] out SunsetPolicy sunsetPolicy ) - { - ArgumentNullException.ThrowIfNull( policyManager ); - - if ( !string.IsNullOrEmpty( name ) ) - { - if ( apiVersion != null && policyManager.TryGetPolicy( name, apiVersion, out sunsetPolicy ) ) - { - return true; - } - else if ( policyManager.TryGetPolicy( name!, out sunsetPolicy ) ) - { - return true; - } - } - - if ( apiVersion != null && policyManager.TryGetPolicy( apiVersion, out sunsetPolicy ) ) - { - return true; - } - - sunsetPolicy = default!; - return false; - } -} \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/LinkList.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/LinkList.cs new file mode 100644 index 00000000..bc68ddca --- /dev/null +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/LinkList.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using System.Collections.ObjectModel; +using System.Globalization; + +internal sealed class LinkList( string relationType ) : Collection +{ + private readonly string relationType = relationType; + + protected override void InsertItem( int index, LinkHeaderValue item ) + { + EnsureRelationType( item ); + base.InsertItem( index, item ); + } + + protected override void SetItem( int index, LinkHeaderValue item ) + { + EnsureRelationType( item ); + base.SetItem( index, item ); + } + + private void EnsureRelationType( LinkHeaderValue item ) + { + if ( !item.RelationType.Equals( relationType, StringComparison.OrdinalIgnoreCase ) ) + { + var message = string.Format( CultureInfo.CurrentCulture, Format.InvalidRelationType, relationType ); + throw new ArgumentException( message, nameof( item ) ); + } + } +} \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/SR.Designer.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/SR.Designer.cs index 9f45b67f..c08c889f 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/SR.Designer.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/SR.Designer.cs @@ -134,11 +134,11 @@ internal static string InvalidOrMalformedHeader { } /// - /// Looks up a localized string similar to The relation type for a sunset policy link must be "sunset".. + /// Looks up a localized string similar to The relation type for a {0} policy link must be "{0}".. /// - internal static string InvalidSunsetRelationType { + internal static string InvalidRelationType { get { - return ResourceManager.GetString("InvalidSunsetRelationType", resourceCulture); + return ResourceManager.GetString("InvalidRelationType", resourceCulture); } } } diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/SR.resx b/src/Abstractions/src/Asp.Versioning.Abstractions/SR.resx index ea5522a8..0b4f2abf 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/SR.resx +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/SR.resx @@ -141,7 +141,7 @@ The header contains invalid or missing values. - - The relation type for a sunset policy link must be "sunset". + + The relation type for a {0} policy link must be "{0}". \ No newline at end of file diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/SunsetPolicy.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/SunsetPolicy.cs index e5e0f4be..28a41062 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/SunsetPolicy.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/SunsetPolicy.cs @@ -2,14 +2,33 @@ namespace Asp.Versioning; -using System.Collections.ObjectModel; - /// /// Represents an API version sunset policy. /// public class SunsetPolicy { - private SunsetLinkList? links; + private LinkList? links; + + /// + /// Gets a read-only list of links that provide information about the sunset policy. + /// + /// A read-only list of HTTP links. + /// If a link is provided, generally only one link is necessary; however, additional + /// links might be provided for different languages or different formats such as a HTML page + /// or a JSON file. + public IList Links => links ??= new( "sunset" ); + + /// + /// Gets a value indicating whether the sunset policy has any associated links. + /// + /// True if the sunset policy has associated links; otherwise, false. + public bool HasLinks => links is not null && links.Count > 0; + + /// + /// Gets the date and time when the API version will be sunset. + /// + /// The date and time when the API version will be sunset, if any. + public DateTimeOffset? Date { get; } /// /// Initializes a new instance of the class. @@ -22,12 +41,13 @@ public SunsetPolicy() { } /// The date and time when the API version will be sunset. /// The optional link which provides information about the sunset policy. public SunsetPolicy( DateTimeOffset date, LinkHeaderValue? link = default ) + : this() { Date = date; if ( link is not null ) { - links = new() { link }; + Links.Add( link ); } } @@ -35,49 +55,5 @@ public SunsetPolicy() { } /// Initializes a new instance of the class. /// /// The link which provides information about the sunset policy. - public SunsetPolicy( LinkHeaderValue link ) => links = new() { link }; - - /// - /// Gets the date and time when the API version will be sunset. - /// - /// The date and time when the API version will be sunset, if any. - public DateTimeOffset? Date { get; } - - /// - /// Gets a value indicating whether the sunset policy has any associated links. - /// - /// True if the sunset policy has associated links; otherwise, false. - public bool HasLinks => links is not null && links.Count > 0; - - /// - /// Gets a read-only list of links that provide information about the sunset policy. - /// - /// A read-only list of HTTP links. - /// If a link is provided, generally only one link is necessary; however, additional - /// links might be provided for different languages or different formats such as a HTML page - /// or a JSON file. - public IList Links => links ??= new(); - - private sealed class SunsetLinkList : Collection - { - protected override void InsertItem( int index, LinkHeaderValue item ) - { - base.InsertItem( index, item ); - EnsureRelationType( item ); - } - - protected override void SetItem( int index, LinkHeaderValue item ) - { - base.SetItem( index, item ); - EnsureRelationType( item ); - } - - private static void EnsureRelationType( LinkHeaderValue item ) - { - if ( !item.RelationType.Equals( "sunset", StringComparison.OrdinalIgnoreCase ) ) - { - throw new ArgumentException( SR.InvalidSunsetRelationType, nameof( item ) ); - } - } - } + public SunsetPolicy( LinkHeaderValue link ) => Links.Add( link ); } \ No newline at end of file diff --git a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ISunsetPolicyBuilderExtensionsTest.cs b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IPolicyBuilderExtensionsTest.cs similarity index 58% rename from src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ISunsetPolicyBuilderExtensionsTest.cs rename to src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IPolicyBuilderExtensionsTest.cs index 052771f0..0b41013d 100644 --- a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ISunsetPolicyBuilderExtensionsTest.cs +++ b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IPolicyBuilderExtensionsTest.cs @@ -2,7 +2,7 @@ namespace Asp.Versioning; -public class ISunsetPolicyBuilderExtensionsTest +public class IPolicyBuilderExtensionsTest { [Fact] public void link_should_build_url_from_string() @@ -11,10 +11,10 @@ public void link_should_build_url_from_string() var builder = Mock.Of(); // act - builder.Link( "http://tempuri.org" ); + builder.Link("http://tempuri.org"); // assert - Mock.Get( builder ).Verify( b => b.Link( new Uri( "http://tempuri.org" ) ) ); + Mock.Get(builder).Verify(b => b.Link(new Uri("http://tempuri.org"))); } [Fact] @@ -22,12 +22,12 @@ public void effective_should_build_date_from_parts() { // arrange var builder = Mock.Of(); - var date = new DateTime( 2022, 2, 1 ); + var date = new DateTime(2022, 2, 1); // act - builder.Effective( 2022, 2, 1 ); + builder.Effective(2022, 2, 1); // assert - Mock.Get( builder ).Verify( b => b.Effective( new( date ) ) ); + Mock.Get(builder).Verify(b => b.SetEffectiveDate(new(date))); } } \ No newline at end of file diff --git a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ISunsetPolicyManagerExtensionsTest.cs b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IPolicyManagerExtensionsTest.cs similarity index 87% rename from src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ISunsetPolicyManagerExtensionsTest.cs rename to src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IPolicyManagerExtensionsTest.cs index 9cc9b693..41733df1 100644 --- a/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/ISunsetPolicyManagerExtensionsTest.cs +++ b/src/Abstractions/test/Asp.Versioning.Abstractions.Tests/IPolicyManagerExtensionsTest.cs @@ -2,13 +2,13 @@ namespace Asp.Versioning; -public class ISunsetPolicyManagerExtensionsTest +public class IPolicyManagerExtensionsTest { [Fact] public void try_get_policy_should_get_global_policy_by_version() { // arrange - var manager = new Mock(); + var manager = new Mock>(); var version = ApiVersion.Default; var expected = new SunsetPolicy(); @@ -26,7 +26,7 @@ public void try_get_policy_should_get_global_policy_by_version() public void try_get_policy_should_get_global_policy_by_name() { // arrange - var manager = new Mock(); + var manager = new Mock>(); var expected = new SunsetPolicy(); manager.Setup( m => m.TryGetPolicy( "Test", default, out expected ) ) @@ -43,7 +43,7 @@ public void try_get_policy_should_get_global_policy_by_name() public void resolve_policy_should_return_most_specific_result() { // arrange - var manager = new Mock(); + var manager = new Mock>(); var expected = new SunsetPolicy(); var other = new SunsetPolicy(); @@ -61,7 +61,7 @@ public void resolve_policy_should_return_most_specific_result() public void resolve_policy_should_fall_back_to_global_result() { // arrange - var manager = new Mock(); + var manager = new Mock>(); var expected = new SunsetPolicy(); var other = new SunsetPolicy(); 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 bb3eed90..24399a61 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 @@ -566,6 +566,7 @@ private void PopulateActionDescriptions( ApiVersion = apiVersion, IsDeprecated = deprecated, SunsetPolicy = SunsetPolicyManager.ResolvePolicyOrDefault( metadata.Name, apiVersion ), + DeprecationPolicy = DeprecationPolicyManager.ResolvePolicyOrDefault( metadata.Name, apiVersion ), Properties = { [typeof( IEdmModel )] = routeBuilderContext.EdmModel }, }; diff --git a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.Tests/Controllers/VersionedMetadataControllerTest.cs b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.Tests/Controllers/VersionedMetadataControllerTest.cs index c39e10b4..0f4db376 100644 --- a/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.Tests/Controllers/VersionedMetadataControllerTest.cs +++ b/src/AspNet/OData/test/Asp.Versioning.WebApi.OData.Tests/Controllers/VersionedMetadataControllerTest.cs @@ -31,8 +31,11 @@ public async Task options_should_return_expected_headers() var resolver = new SimpleDependencyResolver( configuration ); resolver.AddService( - typeof( ISunsetPolicyManager ), + typeof( IPolicyManager ), ( sp, t ) => new SunsetPolicyManager( sp.GetRequiredService().GetApiVersioningOptions() ) ); + resolver.AddService( + typeof( IPolicyManager ), + ( sp, t ) => new DeprecationPolicyManager( sp.GetRequiredService().GetApiVersioningOptions() ) ); configuration.DependencyResolver = resolver; configuration.AddApiVersioning( options => diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ApiExplorer/VersionedApiExplorer.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ApiExplorer/VersionedApiExplorer.cs index c7585844..e2403079 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ApiExplorer/VersionedApiExplorer.cs +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/ApiExplorer/VersionedApiExplorer.cs @@ -32,7 +32,8 @@ public class VersionedApiExplorer : IApiExplorer private readonly ApiExplorerOptions options; private readonly Lazy apiDescriptionsHolder; private IDocumentationProvider? documentationProvider; - private ISunsetPolicyManager? sunsetPolicyManager; + private IPolicyManager? sunsetPolicyManager; + private IPolicyManager? deprecationPolicyManager; /// /// Initializes a new instance of the class. @@ -98,13 +99,23 @@ public IDocumentationProvider DocumentationProvider /// /// Gets or sets the manager used to resolve sunset policies for API descriptions. /// - /// The configured sunset policy manager. - protected ISunsetPolicyManager SunsetPolicyManager + /// The configured sunset policy manager. + protected IPolicyManager SunsetPolicyManager { get => sunsetPolicyManager ??= Configuration.GetSunsetPolicyManager(); set => sunsetPolicyManager = value; } + /// + /// Gets or sets the manager used to resolve deprecation policies for API descriptions. + /// + /// The configured deprecation policy manager. + protected IPolicyManager DeprecationPolicyManager + { + get => deprecationPolicyManager ??= Configuration.GetDeprecationPolicyManager(); + set => deprecationPolicyManager = value; + } + /// /// Gets a collection of HTTP methods supported by the action. /// @@ -227,11 +238,11 @@ protected virtual ApiDescriptionGroupCollection InitializeApiDescriptions() } var routes = FlattenRoutes( Configuration.Routes ).ToArray(); - var policyManager = Configuration.GetSunsetPolicyManager(); foreach ( var apiVersion in FlattenApiVersions( controllerMappings ) ) { - var sunsetPolicy = policyManager.TryGetPolicy( apiVersion, out var policy ) ? policy : default; + SunsetPolicyManager.TryGetPolicy( apiVersion, out var sunsetPolicy ); + DeprecationPolicyManager.TryGetPolicy( apiVersion, out var deprecationPolicy ); for ( var i = 0; i < routes.Length; i++ ) { @@ -244,6 +255,7 @@ protected virtual ApiDescriptionGroupCollection InitializeApiDescriptions() ExploreRouteControllers( controllerMappings, route, apiVersion ); apiDescriptionGroup.SunsetPolicy = sunsetPolicy; + apiDescriptionGroup.DeprecationPolicy = deprecationPolicy; // Remove ApiDescription that will lead to ambiguous action matching. // E.g. a controller with Post() and PostComment(). When the route template is {controller}, it produces POST /controller and POST /controller. @@ -878,6 +890,7 @@ private void PopulateActionDescriptions( ApiVersion = apiVersion, IsDeprecated = deprecated, SunsetPolicy = SunsetPolicyManager.ResolvePolicyOrDefault( metadata.Name, apiVersion ), + DeprecationPolicy = DeprecationPolicyManager.ResolvePolicyOrDefault( metadata.Name, apiVersion ), }; foreach ( var supportedResponseFormatter in supportedResponseFormatters ) diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Description/ApiDescriptionGroup.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Description/ApiDescriptionGroup.cs index 469c44f5..a8ce4ccc 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Description/ApiDescriptionGroup.cs +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Description/ApiDescriptionGroup.cs @@ -51,6 +51,12 @@ public ApiDescriptionGroup( ApiVersion apiVersion, string name ) /// The defined sunset policy defined for the API, if any. public SunsetPolicy? SunsetPolicy { get; set; } + /// + /// Gets or sets described API deprecation policy. + /// + /// The defined deprecation policy defined for the API, if any. + public DeprecationPolicy? DeprecationPolicy { get; set; } + /// /// Gets a collection of API descriptions for the current version. /// diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Description/VersionedApiDescription.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Description/VersionedApiDescription.cs index da07e7d1..5087f286 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Description/VersionedApiDescription.cs +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi.ApiExplorer/Description/VersionedApiDescription.cs @@ -49,6 +49,12 @@ public ApiVersion ApiVersion /// The defined sunset policy defined for the API, if any. public SunsetPolicy? SunsetPolicy { get; set; } + /// + /// Gets or sets the described API deprecation policy. + /// + /// The defined deprecation policy defined for the API, if any. + public DeprecationPolicy? DeprecationPolicy { get; set; } + /// /// Gets or sets the response description. /// diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Asp.Versioning.WebApi.csproj b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Asp.Versioning.WebApi.csproj index 761b386b..e226b019 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Asp.Versioning.WebApi.csproj +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Asp.Versioning.WebApi.csproj @@ -21,6 +21,7 @@ + diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Dependencies/DefaultContainer.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Dependencies/DefaultContainer.cs index 7725393c..dc142649 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Dependencies/DefaultContainer.cs +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/Dependencies/DefaultContainer.cs @@ -18,7 +18,8 @@ internal DefaultContainer() container.AddService( typeof( IApiVersionParser ), static ( sc, t ) => ApiVersionParser.Default ); container.AddService( typeof( IControllerNameConvention ), static ( sc, t ) => ControllerNameConvention.Default ); container.AddService( typeof( IProblemDetailsFactory ), static ( sc, t ) => new ProblemDetailsFactory() ); - container.AddService( typeof( ISunsetPolicyManager ), NewSunsetPolicyManager ); + container.AddService( typeof( IPolicyManager ), NewSunsetPolicyManager ); + container.AddService( typeof( IPolicyManager ), NewDeprecationPolicyManager ); container.AddService( typeof( IReportApiVersions ), NewApiVersionReporter ); } @@ -66,17 +67,21 @@ public IEnumerable GetServices( Type serviceType ) private static ApiVersioningOptions GetApiVersioningOptions( IServiceProvider serviceProvider ) => (ApiVersioningOptions) serviceProvider.GetService( typeof( ApiVersioningOptions ) ); - private static ISunsetPolicyManager NewSunsetPolicyManager( IServiceProvider serviceProvider, Type type ) => + private static IPolicyManager NewSunsetPolicyManager( IServiceProvider serviceProvider, Type type ) => new SunsetPolicyManager( GetApiVersioningOptions( serviceProvider ) ); + private static IPolicyManager NewDeprecationPolicyManager( IServiceProvider serviceProvider, Type type ) => + new DeprecationPolicyManager( GetApiVersioningOptions( serviceProvider ) ); + private static IReportApiVersions NewApiVersionReporter( IServiceProvider serviceProvider, Type type ) { var options = GetApiVersioningOptions( serviceProvider ); if ( options.ReportApiVersions ) { - var sunsetPolicyManager = (ISunsetPolicyManager) serviceProvider.GetService( typeof( ISunsetPolicyManager ) ); - return new DefaultApiVersionReporter( sunsetPolicyManager ); + var sunsetPolicyManager = (IPolicyManager) serviceProvider.GetService( typeof( IPolicyManager ) ); + var deprecationPolicyManager = (IPolicyManager) serviceProvider.GetService( typeof( IPolicyManager ) ); + return new DefaultApiVersionReporter( sunsetPolicyManager, deprecationPolicyManager ); } return new DoNotReportApiVersions(); diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/DependencyResolverExtensions.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/DependencyResolverExtensions.cs index 6cad7f56..13fec24a 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/DependencyResolverExtensions.cs +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/DependencyResolverExtensions.cs @@ -35,7 +35,11 @@ internal static IProblemDetailsFactory GetProblemDetailsFactory( this HttpConfig configuration.DependencyResolver.GetService() ?? configuration.ApiVersioningServices().GetRequiredService(); - internal static ISunsetPolicyManager GetSunsetPolicyManager( this HttpConfiguration configuration ) => - configuration.DependencyResolver.GetService() ?? - configuration.ApiVersioningServices().GetRequiredService(); + internal static IPolicyManager GetSunsetPolicyManager( this HttpConfiguration configuration ) => + configuration.DependencyResolver.GetService>() ?? + configuration.ApiVersioningServices().GetRequiredService>(); + + internal static IPolicyManager GetDeprecationPolicyManager( this HttpConfiguration configuration ) => + configuration.DependencyResolver.GetService>() ?? + configuration.ApiVersioningServices().GetRequiredService>(); } \ No newline at end of file diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/DeprecationPolicyManager.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/DeprecationPolicyManager.cs new file mode 100644 index 00000000..2188db8c --- /dev/null +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/DeprecationPolicyManager.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// +/// Provides additional content specific to ASP.NET Web API. +/// +public partial class DeprecationPolicyManager +{ + private readonly ApiVersioningOptions options; + + /// + protected override ApiVersioningOptions Options => options; + + /// + /// Initializes a new instance of the class. + /// + /// The associated API versioning options. + public DeprecationPolicyManager( ApiVersioningOptions options ) => this.options = options; +} \ No newline at end of file diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/README.md b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/README.md index 92fcbae3..2a24c16c 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/README.md +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/README.md @@ -1,5 +1,5 @@ | :mega: Formerly [Microsoft.AspNet.WebApi.Versioning](https://www.nuget.org/packages/Microsoft.AspNet.WebApi.Versioning/). See the [announcement](https://github.com/dotnet/aspnet-api-versioning/discussions/807). | -|-| +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ASP.NET API versioning gives you a powerful, but easy-to-use method for adding API versioning semantics to your new and existing REST services built with ASP.NET Web API. The API versioning extensions define simple metadata attributes @@ -13,9 +13,8 @@ and conventions that you use to describe which API versions are implemented by y - Asp.Versioning.IApiVersionSelector - Asp.Versioning.IReportApiVersions - Asp.Versioning.ISunsetPolicyBuilder -- Asp.Versioning.ISunsetPolicyManager +- Asp.Versioning.IPolicyManager - Asp.Versioning.QueryStringApiVersionReader - Asp.Versioning.ReportApiVersionsAttribute ## Release Notes - diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/SunsetPolicyManager.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/SunsetPolicyManager.cs index 78af0cdf..c800e774 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/SunsetPolicyManager.cs +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/SunsetPolicyManager.cs @@ -9,6 +9,9 @@ public partial class SunsetPolicyManager { private readonly ApiVersioningOptions options; + /// + protected override ApiVersioningOptions Options => options; + /// /// Initializes a new instance of the class. /// diff --git a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/System.Net.Http/HttpResponseMessageExtensions.cs b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/System.Net.Http/HttpResponseMessageExtensions.cs index 2f9e1510..7af8d37f 100644 --- a/src/AspNet/WebApi/src/Asp.Versioning.WebApi/System.Net.Http/HttpResponseMessageExtensions.cs +++ b/src/AspNet/WebApi/src/Asp.Versioning.WebApi/System.Net.Http/HttpResponseMessageExtensions.cs @@ -13,6 +13,7 @@ namespace System.Net.Http; public static class HttpResponseMessageExtensions { private const string Sunset = nameof( Sunset ); + private const string Deprecation = nameof( Deprecation ); private const string Link = nameof( Link ); /// @@ -35,6 +36,28 @@ public static void WriteSunsetPolicy( this HttpResponseMessage response, SunsetP AddLinkHeaders( headers, sunsetPolicy.Links ); } + /// + /// Writes the sunset policy to the specified HTTP response. + /// + /// The HTTP response to write to. + /// The deprecation policy to write. + public static void WriteDeprecationPolicy( this HttpResponseMessage response, DeprecationPolicy deprecationPolicy ) + { + ArgumentNullException.ThrowIfNull( response ); + ArgumentNullException.ThrowIfNull( deprecationPolicy ); + + var headers = response.Headers; + + if ( deprecationPolicy.Date is { } when ) + { + var unixTimestamp = when.ToUnixTimeSeconds(); + + headers.Add( Deprecation, unixTimestamp.ToString( "'@'0" ) ); + } + + AddLinkHeaders( headers, deprecationPolicy.Links ); + } + private static void AddLinkHeaders( HttpResponseHeaders headers, IList links ) { var values = headers.TryGetValues( Link, out var existing ) diff --git a/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/DefaultApiVersionReporterTest.cs b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/DefaultApiVersionReporterTest.cs index 23e70e9e..130a4e7d 100644 --- a/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/DefaultApiVersionReporterTest.cs +++ b/src/AspNet/WebApi/test/Asp.Versioning.WebApi.Tests/DefaultApiVersionReporterTest.cs @@ -13,8 +13,9 @@ public class DefaultApiVersionReporterTest public void report_should_add_expected_headers() { // arrange - var sunsetDate = DateTimeOffset.Now; - var reporter = new DefaultApiVersionReporter( new TestSunsetPolicyManager( sunsetDate ) ); + var sunsetDate = DateTimeOffset.UtcNow.AddDays( 2 ); + var deprecationDate = DateTimeOffset.UtcNow.AddDays( 1 ); + var reporter = new DefaultApiVersionReporter( new TestSunsetPolicyManager( sunsetDate ), new TestDeprecationPolicyManager( deprecationDate ) ); var configuration = new HttpConfiguration(); var request = new HttpRequestMessage(); var response = new HttpResponseMessage( OK ) { RequestMessage = request }; @@ -50,19 +51,26 @@ public void report_should_add_expected_headers() // assert var headers = response.Headers; + // This line uses an explicit calculation of the unix timestamp to surface any bugs in the backport of ToUnixTimeSeconds. + var unixTimestamp = (long) deprecationDate.Subtract( new DateTime( 1970, 1, 1 ) ).TotalSeconds; + headers.GetValues( "api-supported-versions" ).Should().Equal( "1.0, 2.0" ); headers.GetValues( "api-deprecated-versions" ).Should().Equal( "0.9" ); headers.GetValues( "Sunset" ) - .Single() .Should() - .Be( sunsetDate.ToString( "r" ) ); + .ContainSingle( sunsetDate.ToString( "r" ) ); + headers.GetValues( "Deprecation" ) + .Should() + .ContainSingle( $"@{unixTimestamp}" ); headers.GetValues( "Link" ) - .Single() .Should() - .Be( "; rel=\"sunset\"" ); + .BeEquivalentTo( [ + "; rel=\"sunset\"", + "; rel=\"deprecation\"", + ] ); } - private sealed class TestSunsetPolicyManager : ISunsetPolicyManager + private sealed class TestSunsetPolicyManager : IPolicyManager { private readonly DateTimeOffset sunsetDate; @@ -73,7 +81,7 @@ public bool TryGetPolicy( string name, ApiVersion apiVersion, out SunsetPolicy s { if ( name == "Test" ) { - var link = new LinkHeaderValue( new Uri( "http://docs.api.com/policy.html" ), "sunset" ); + var link = new LinkHeaderValue( new Uri( "http://docs.api.com/sunset.html" ), "sunset" ); sunsetPolicy = new( sunsetDate, link ); return true; } @@ -82,4 +90,25 @@ public bool TryGetPolicy( string name, ApiVersion apiVersion, out SunsetPolicy s return false; } } + + private sealed class TestDeprecationPolicyManager : IPolicyManager + { + private readonly DateTimeOffset deprecationDate; + + public TestDeprecationPolicyManager( DateTimeOffset deprecationDate ) => + this.deprecationDate = deprecationDate; + + public bool TryGetPolicy( string name, ApiVersion apiVersion, out DeprecationPolicy deprecationPolicy ) + { + if ( name == "Test" ) + { + var link = new LinkHeaderValue( new Uri( "http://docs.api.com/deprecation.html" ), "deprecation" ); + deprecationPolicy = new( deprecationDate, link ); + return true; + } + + deprecationPolicy = default; + return false; + } + } } \ No newline at end of file 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 8254d7d1..9850a820 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 @@ -185,10 +185,11 @@ protected virtual void ExploreQueryOptions( IEnumerable apiDescr [MethodImpl( MethodImplOptions.AggressiveInlining )] private static int ApiVersioningOrder() { - var policyManager = new SunsetPolicyManager( Opts.Create( new ApiVersioningOptions() ) ); + var sunsetPolicyManager = new SunsetPolicyManager( Opts.Create( new ApiVersioningOptions() ) ); + var deprecationPolicyManager = new DeprecationPolicyManager( Opts.Create( new ApiVersioningOptions() ) ); var options = Opts.Create( new ApiExplorerOptions() ); var provider = new EmptyModelMetadataProvider(); - return new VersionedApiDescriptionProvider( policyManager, provider, options ).Order; + return new VersionedApiDescriptionProvider( sunsetPolicyManager, deprecationPolicyManager, provider, options ).Order; } [MethodImpl( MethodImplOptions.AggressiveInlining )] diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/ApiVersionDescription.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/ApiVersionDescription.cs index 897ed8f5..79d07314 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/ApiVersionDescription.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/ApiVersionDescription.cs @@ -14,16 +14,19 @@ public class ApiVersionDescription /// The group name for the API version. /// Indicates whether the API version is deprecated. /// The defined sunset policy, if any. + /// The defined deprecation policy, if any. public ApiVersionDescription( ApiVersion apiVersion, string groupName, bool deprecated = false, - SunsetPolicy? sunsetPolicy = default ) + SunsetPolicy? sunsetPolicy = default, + DeprecationPolicy? deprecationPolicy = default ) { ApiVersion = apiVersion; GroupName = groupName; IsDeprecated = deprecated; SunsetPolicy = sunsetPolicy; + DeprecationPolicy = deprecationPolicy; } /// @@ -54,4 +57,10 @@ public ApiVersionDescription( /// /// The defined sunset policy defined for the API, if any. public SunsetPolicy? SunsetPolicy { get; } + + /// + /// Gets described API deprecation policy. + /// + /// The defined deprecation policy defined for the API, if any. + public DeprecationPolicy? DeprecationPolicy { get; } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs index f8948ab4..da09790e 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs @@ -143,7 +143,8 @@ private static void AddApiVersioningServices( IServiceCollection services ) services.AddSingleton( static sp => (IApiVersionParameterSource) sp.GetRequiredService>().Value.ApiVersionReader ); services.AddSingleton( static sp => sp.GetRequiredService>().Value.ApiVersionSelector ); services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton, SunsetPolicyManager>(); + services.TryAddSingleton, DeprecationPolicyManager>(); services.TryAddEnumerable( Transient, ValidateApiVersioningOptions>() ); services.TryAddEnumerable( Transient, ApiVersioningRouteOptionsSetup>() ); services.TryAddEnumerable( Singleton() ); @@ -281,7 +282,7 @@ private static void TryAddErrorObjectJsonOptions( IServiceCollection services ) } } -// TEMP: this is a marker class to test whether Error Objects have been explicitly added. remove in 9.0+ + // TEMP: this is a marker class to test whether Error Objects have been explicitly added. remove in 9.0+ #pragma warning disable CA1812 // Avoid uninstantiated internal classes private sealed class ErrorObjectsAdded { } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DeprecationPolicyManager.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DeprecationPolicyManager.cs new file mode 100644 index 00000000..1778c21c --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DeprecationPolicyManager.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using Microsoft.Extensions.Options; + +/// +/// Provides additional content specific to ASP.NET Core. +/// +public partial class DeprecationPolicyManager +{ + private readonly IOptions options; + + /// + protected override ApiVersioningOptions Options => options.Value; + + /// + /// Initializes a new instance of the class. + /// + /// The associated API versioning options. + public DeprecationPolicyManager( IOptions options ) => this.options = options; +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Http/HttpResponseExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Http/HttpResponseExtensions.cs index 9b8ed20a..d638d1fb 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Http/HttpResponseExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Http/HttpResponseExtensions.cs @@ -5,6 +5,7 @@ namespace Microsoft.AspNetCore.Http; using Asp.Versioning; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; +using System.Globalization; /// /// Provides extension methods for . @@ -13,6 +14,7 @@ namespace Microsoft.AspNetCore.Http; public static class HttpResponseExtensions { private const string Sunset = nameof( Sunset ); + private const string Deprecation = nameof( Deprecation ); private const string Link = nameof( Link ); /// @@ -44,6 +46,35 @@ public static void WriteSunsetPolicy( this HttpResponse response, SunsetPolicy s AddLinkHeaders( headers, sunsetPolicy.Links ); } + /// + /// Writes the deprecation policy to the specified HTTP response. + /// + /// The HTTP response to write to. + /// The deprecation policy to write. + [CLSCompliant( false )] + public static void WriteDeprecationPolicy( this HttpResponse response, DeprecationPolicy deprecationPolicy ) + { + ArgumentNullException.ThrowIfNull( response ); + ArgumentNullException.ThrowIfNull( deprecationPolicy ); + + var headers = response.Headers; + + if ( headers.ContainsKey( Deprecation ) ) + { + // the 'Deprecation' header is present, assume the headers have been written. + // this can happen when ApiVersioningOptions.ReportApiVersions = true + // and [ReportApiVersions] are both applied + return; + } + + if ( deprecationPolicy.Date is { } when ) + { + headers[Deprecation] = when.ToUnixTimeSeconds().ToString( "'@'0", CultureInfo.InvariantCulture ); + } + + AddLinkHeaders( headers, deprecationPolicy.Links ); + } + private static void AddLinkHeaders( IHeaderDictionary headers, IList links ) { var values = new string[links.Count]; diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/README.md b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/README.md index cbb1e7ee..25a4fde9 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/README.md +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/README.md @@ -15,8 +15,7 @@ Minimal APIs. For additional functionality provided by ASP.NET Core MVC use the - Asp.Versioning.IApiVersionSelector - Asp.Versioning.IReportApiVersions - Asp.Versioning.ISunsetPolicyBuilder -- Asp.Versioning.ISunsetPolicyManager +- Asp.Versioning.IPolicyManager - Asp.Versioning.QueryStringApiVersionReader ## Release Notes - diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SunsetPolicyManager.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SunsetPolicyManager.cs index aa16d930..ad3da6d2 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SunsetPolicyManager.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SunsetPolicyManager.cs @@ -11,6 +11,9 @@ public partial class SunsetPolicyManager { private readonly IOptions options; + /// + protected override ApiVersioningOptions Options => options.Value; + /// /// Initializes a new instance of the class. /// diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiDescriptionExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiDescriptionExtensions.cs index 30aaf0cd..7f17661f 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiDescriptionExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiDescriptionExtensions.cs @@ -30,6 +30,13 @@ public static class ApiDescriptionExtensions /// The defined sunset policy defined for the API or null. public static SunsetPolicy? GetSunsetPolicy( this ApiDescription apiDescription ) => apiDescription.GetProperty(); + /// + /// Gets the API deprecation policy associated with the API description, if any. + /// + /// The API description to get the deprecation policy for. + /// The defined deprecation policy defined for the API or null. + public static DeprecationPolicy? GetDeprecationPolicy( this ApiDescription apiDescription ) => apiDescription.GetProperty(); + /// /// Gets a value indicating whether the associated API description is deprecated. /// @@ -65,11 +72,20 @@ public static bool IsDeprecated( this ApiDescription apiDescription ) /// Sets the API sunset policy associated with the API description. /// /// The API description to set the sunset policy for. - /// The associated sunst policy. + /// The associated sunset policy. /// This API is meant for infrastructure and should not be used by application code. [EditorBrowsable( EditorBrowsableState.Never )] public static void SetSunsetPolicy( this ApiDescription apiDescription, SunsetPolicy sunsetPolicy ) => apiDescription.SetProperty( sunsetPolicy ); + /// + /// Sets the API deprecation policy associated with the API description. + /// + /// The API description to set the sunset policy for. + /// The associated deprecation policy. + /// This API is meant for infrastructure and should not be used by application code. + [EditorBrowsable( EditorBrowsableState.Never )] + public static void SetDeprecationPolicy( this ApiDescription apiDescription, DeprecationPolicy deprecationPolicy ) => apiDescription.SetProperty( deprecationPolicy ); + /// /// Attempts to update the relate path of the specified API description and remove the corresponding parameter according to the specified options. /// diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionDescriptionProviderFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionDescriptionProviderFactory.cs index cec78062..d625fb23 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionDescriptionProviderFactory.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ApiVersionDescriptionProviderFactory.cs @@ -11,18 +11,21 @@ namespace Microsoft.AspNetCore.Builder; internal sealed class ApiVersionDescriptionProviderFactory : IApiVersionDescriptionProviderFactory { - private readonly ISunsetPolicyManager sunsetPolicyManager; + private readonly IPolicyManager sunsetPolicyManager; + private readonly IPolicyManager deprecationPolicyManager; private readonly IApiVersionMetadataCollationProvider[] providers; private readonly IEndpointInspector endpointInspector; private readonly IOptions options; public ApiVersionDescriptionProviderFactory( - ISunsetPolicyManager sunsetPolicyManager, + IPolicyManager sunsetPolicyManager, + IPolicyManager deprecationPolicyManager, IEnumerable providers, IEndpointInspector endpointInspector, IOptions options ) { this.sunsetPolicyManager = sunsetPolicyManager; + this.deprecationPolicyManager = deprecationPolicyManager; this.providers = providers.ToArray(); this.endpointInspector = endpointInspector; this.options = options; @@ -37,6 +40,6 @@ public IApiVersionDescriptionProvider Create( EndpointDataSource endpointDataSou collators.AddRange( providers ); - return new DefaultApiVersionDescriptionProvider( collators, sunsetPolicyManager, options ); + return new DefaultApiVersionDescriptionProvider( collators, sunsetPolicyManager, deprecationPolicyManager, options ); } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DefaultApiVersionDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DefaultApiVersionDescriptionProvider.cs index 22151e11..e018222e 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DefaultApiVersionDescriptionProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DefaultApiVersionDescriptionProvider.cs @@ -19,24 +19,33 @@ public class DefaultApiVersionDescriptionProvider : IApiVersionDescriptionProvid /// /// The sequence of /// API version metadata collation providers.. - /// The manager used to resolve sunset policies. + /// The manager used to resolve sunset policies. + /// The manager used to resolve deprecation policies. /// The container of configured /// API explorer options. public DefaultApiVersionDescriptionProvider( IEnumerable providers, - ISunsetPolicyManager sunsetPolicyManager, + IPolicyManager sunsetPolicyManager, + IPolicyManager deprecationPolicyManager, IOptions apiExplorerOptions ) { collection = new( Describe, providers ?? throw new ArgumentNullException( nameof( providers ) ) ); SunsetPolicyManager = sunsetPolicyManager; + DeprecationPolicyManager = deprecationPolicyManager; options = apiExplorerOptions; } /// /// Gets the manager used to resolve sunset policies. /// - /// The associated sunset policy manager. - protected ISunsetPolicyManager SunsetPolicyManager { get; } + /// The associated sunset policy manager. + protected IPolicyManager SunsetPolicyManager { get; } + + /// + /// Gets the manager used to resolve deprecation policies. + /// + /// The associated deprecation policy manager. + protected IPolicyManager DeprecationPolicyManager { get; } /// /// Gets the options associated with the API explorer. @@ -64,7 +73,7 @@ protected virtual IReadOnlyList Describe( IReadOnlyList(); diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs index c1a689fe..d8d99d1e 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -60,7 +60,8 @@ private static void AddApiExplorerServices( IApiVersioningBuilder builder ) services.TryAddEnumerable( Transient( static sp => new( - sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>() ) ) ); diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs index 294db52c..6e7fa38e 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs @@ -19,24 +19,33 @@ public class GroupedApiVersionDescriptionProvider : IApiVersionDescriptionProvid /// /// The sequence of /// API version metadata collation providers.. - /// The manager used to resolve sunset policies. + /// The manager used to resolve sunset policies. + /// The manager used to resolve deprecation policies. /// The container of configured /// API explorer options. public GroupedApiVersionDescriptionProvider( IEnumerable providers, - ISunsetPolicyManager sunsetPolicyManager, + IPolicyManager sunsetPolicyManager, + IPolicyManager deprecationPolicyManager, IOptions apiExplorerOptions ) { collection = new( Describe, providers ?? throw new ArgumentNullException( nameof( providers ) ) ); SunsetPolicyManager = sunsetPolicyManager; + DeprecationPolicyManager = deprecationPolicyManager; options = apiExplorerOptions; } /// /// Gets the manager used to resolve sunset policies. /// - /// The associated sunset policy manager. - protected ISunsetPolicyManager SunsetPolicyManager { get; } + /// The associated sunset policy manager. + protected IPolicyManager SunsetPolicyManager { get; } + + /// + /// Gets the manager used to resolve deprecation policies. + /// + /// The associated deprecation policy manager. + protected IPolicyManager DeprecationPolicyManager { get; } /// /// Gets the options associated with the API explorer. @@ -57,7 +66,7 @@ public GroupedApiVersionDescriptionProvider( protected virtual IReadOnlyList Describe( IReadOnlyList metadata ) { ArgumentNullException.ThrowIfNull( metadata ); - return DescriptionProvider.Describe( metadata, SunsetPolicyManager, Options ); + return DescriptionProvider.Describe( metadata, SunsetPolicyManager, DeprecationPolicyManager, Options ); } /// diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/DescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/DescriptionProvider.cs index ce3a0dbd..064a7736 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/DescriptionProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Internal/DescriptionProvider.cs @@ -9,7 +9,8 @@ internal static class DescriptionProvider { internal static ApiVersionDescription[] Describe( IReadOnlyList metadata, - ISunsetPolicyManager sunsetPolicyManager, + IPolicyManager sunsetPolicyManager, + IPolicyManager deprecationPolicyManager, ApiExplorerOptions options ) where T : IGroupedApiVersionMetadata, IEquatable { @@ -18,8 +19,8 @@ internal static ApiVersionDescription[] Describe( var deprecated = new HashSet(); BucketizeApiVersions( metadata, supported, deprecated, options ); - AppendDescriptions( descriptions, supported, sunsetPolicyManager, options, deprecated: false ); - AppendDescriptions( descriptions, deprecated, sunsetPolicyManager, options, deprecated: true ); + AppendDescriptions( descriptions, supported, sunsetPolicyManager, deprecationPolicyManager, options, deprecated: false ); + AppendDescriptions( descriptions, deprecated, sunsetPolicyManager, deprecationPolicyManager, options, deprecated: true ); return [.. descriptions]; } @@ -80,7 +81,8 @@ private static void BucketizeApiVersions( private static void AppendDescriptions( SortedSet descriptions, HashSet versions, - ISunsetPolicyManager sunsetPolicyManager, + IPolicyManager sunsetPolicyManager, + IPolicyManager deprecationPolicyManager, ApiExplorerOptions options, bool deprecated ) { @@ -100,8 +102,10 @@ private static void AppendDescriptions( formattedGroupName = formatGroupName( formattedGroupName, version.ToString( format, CurrentCulture ) ); } - var sunsetPolicy = sunsetPolicyManager.TryGetPolicy( version, out var policy ) ? policy : default; - descriptions.Add( new( version, formattedGroupName, deprecated, sunsetPolicy ) ); + sunsetPolicyManager.TryGetPolicy( version, out var sunsetPolicy ); + deprecationPolicyManager.TryGetPolicy( version, out var deprecationPolicy ); + + descriptions.Add( new( version, formattedGroupName, deprecated, sunsetPolicy, deprecationPolicy ) ); } } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs index e1f22177..0f5ac457 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs @@ -26,16 +26,19 @@ public class VersionedApiDescriptionProvider : IApiDescriptionProvider /// /// Initializes a new instance of the class. /// - /// The manager used to resolve sunset policies. + /// The manager used to resolve sunset policies. + /// The manager used to resolve deprecation policies. /// The provider used to retrieve model metadata. /// The container of configured /// API explorer options. public VersionedApiDescriptionProvider( - ISunsetPolicyManager sunsetPolicyManager, + IPolicyManager sunsetPolicyManager, + IPolicyManager deprecationPolicyManager, IModelMetadataProvider modelMetadataProvider, IOptions options ) : this( sunsetPolicyManager, + deprecationPolicyManager, modelMetadataProvider, new SimpleConstraintResolver( options ?? throw new ArgumentNullException( nameof( options ) ) ), options ) @@ -45,12 +48,14 @@ public VersionedApiDescriptionProvider( // intentionally hiding IInlineConstraintResolver from public signature until ASP.NET Core fixes their bug // BUG: https://github.com/dotnet/aspnetcore/issues/41773 internal VersionedApiDescriptionProvider( - ISunsetPolicyManager sunsetPolicyManager, + IPolicyManager sunsetPolicyManager, + IPolicyManager deprecationPolicyManager, IModelMetadataProvider modelMetadataProvider, IInlineConstraintResolver constraintResolver, IOptions options ) { SunsetPolicyManager = sunsetPolicyManager; + DeprecationPolicyManager = deprecationPolicyManager; ModelMetadataProvider = modelMetadataProvider; this.constraintResolver = constraintResolver; this.options = options; @@ -65,8 +70,14 @@ internal VersionedApiDescriptionProvider( /// /// Gets the manager used to resolve sunset policies. /// - /// The associated sunset policy manager. - protected ISunsetPolicyManager SunsetPolicyManager { get; } + /// The associated sunset policy manager. + protected IPolicyManager SunsetPolicyManager { get; } + + /// + /// Gets the manager used to resolve deprecation policies. + /// + /// The associated deprecation policy manager. + protected IPolicyManager DeprecationPolicyManager { get; } /// /// Gets the options associated with the API explorer. @@ -170,9 +181,14 @@ public virtual void OnProvidersExecuted( ApiDescriptionProviderContext context ) groupResult.GroupName = formatGroupName( groupResult.GroupName, formattedVersion ); } - if ( SunsetPolicyManager.TryResolvePolicy( metadata.Name, version, out var policy ) ) + if ( SunsetPolicyManager.TryResolvePolicy( metadata.Name, version, out var sunsetPolicy ) ) + { + groupResult.SetSunsetPolicy( sunsetPolicy ); + } + + if ( DeprecationPolicyManager.TryResolvePolicy( metadata.Name, version, out var deprecationPolicy ) ) { - groupResult.SetSunsetPolicy( policy ); + groupResult.SetDeprecationPolicy( deprecationPolicy ); } groupResult.SetApiVersion( version ); diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/DefaultApiVersionReporterTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/DefaultApiVersionReporterTest.cs index 9125c9ae..39971144 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/DefaultApiVersionReporterTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/DefaultApiVersionReporterTest.cs @@ -12,8 +12,9 @@ public class DefaultApiVersionReporterTest public void report_should_add_expected_headers() { // arrange - var sunsetDate = DateTimeOffset.Now; - var reporter = new DefaultApiVersionReporter( new TestSunsetPolicyManager( sunsetDate ) ); + var sunsetDate = DateTimeOffset.UtcNow.AddDays( 2 ); + var deprecationDate = DateTimeOffset.UtcNow.AddDays( 1 ); + var reporter = new DefaultApiVersionReporter( new TestSunsetPolicyManager( sunsetDate ), new TestDeprecationPolicyManager( deprecationDate ) ); var httpContext = new Mock(); var features = new Mock(); var query = new Mock(); @@ -60,17 +61,25 @@ public void report_should_add_expected_headers() reporter.Report( response.Object, model ); // assert + var unixTimestamp = deprecationDate.ToUnixTimeSeconds(); + headers["api-supported-versions"].Should().Equal( "1.0, 2.0" ); headers["api-deprecated-versions"].Should().Equal( "0.9" ); - headers["Sunset"].Single() - .Should() - .Be( sunsetDate.ToString( "r" ) ); - headers["Link"].Single() - .Should() - .Be( "; rel=\"sunset\"" ); + headers["Sunset"] + .Should() + .ContainSingle( sunsetDate.ToString( "r" ) ); + headers["Deprecation"] + .Should() + .ContainSingle( $"@{unixTimestamp}" ); + headers["Link"] + .Should() + .BeEquivalentTo( [ + "; rel=\"sunset\"", + "; rel=\"deprecation\"", + ] ); } - private sealed class TestSunsetPolicyManager : ISunsetPolicyManager + private sealed class TestSunsetPolicyManager : IPolicyManager { private readonly DateTimeOffset sunsetDate; @@ -81,7 +90,7 @@ public bool TryGetPolicy( string name, ApiVersion apiVersion, out SunsetPolicy s { if ( name == "Test" ) { - var link = new LinkHeaderValue( new Uri( "http://docs.api.com/policy.html" ), "sunset" ); + var link = new LinkHeaderValue( new Uri( "http://docs.api.com/sunset.html" ), "sunset" ); sunsetPolicy = new( sunsetDate, link ); return true; } @@ -90,4 +99,25 @@ public bool TryGetPolicy( string name, ApiVersion apiVersion, out SunsetPolicy s return false; } } + + private sealed class TestDeprecationPolicyManager : IPolicyManager + { + private readonly DateTimeOffset deprecationDate; + + public TestDeprecationPolicyManager( DateTimeOffset deprecationDate ) => + this.deprecationDate = deprecationDate; + + public bool TryGetPolicy( string name, ApiVersion apiVersion, out DeprecationPolicy deprecationPolicy ) + { + if ( name == "Test" ) + { + var link = new LinkHeaderValue( new Uri( "http://docs.api.com/deprecation.html" ), "deprecation" ); + deprecationPolicy = new( deprecationDate, link ); + return true; + } + + deprecationPolicy = default; + return false; + } + } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/DefaultApiVersionDescriptionProviderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/DefaultApiVersionDescriptionProviderTest.cs index 24460974..da5cf86e 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/DefaultApiVersionDescriptionProviderTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/DefaultApiVersionDescriptionProviderTest.cs @@ -16,7 +16,8 @@ public void api_version_descriptions_should_collate_expected_versions() new EndpointApiVersionMetadataCollationProvider( new TestEndpointDataSource() ), new ActionApiVersionMetadataCollationProvider( new TestActionDescriptorCollectionProvider() ), }, - Mock.Of(), + Mock.Of>(), + Mock.Of>(), Options.Create( new ApiExplorerOptions() { GroupNameFormat = "'v'VVV" } ) ); // act @@ -39,7 +40,7 @@ public void api_version_descriptions_should_apply_sunset_policy() // arrange var expected = new SunsetPolicy(); var apiVersion = new ApiVersion( 0.9 ); - var policyManager = new Mock(); + var policyManager = new Mock>(); policyManager.Setup( pm => pm.TryGetPolicy( default, apiVersion, out expected ) ).Returns( true ); @@ -50,6 +51,7 @@ public void api_version_descriptions_should_apply_sunset_policy() new ActionApiVersionMetadataCollationProvider( new TestActionDescriptorCollectionProvider() ), }, policyManager.Object, + Mock.Of>(), Options.Create( new ApiExplorerOptions() { GroupNameFormat = "'v'VVV" } ) ); // act diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/GroupedApiVersionDescriptionProviderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/GroupedApiVersionDescriptionProviderTest.cs index 9be0191f..946df754 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/GroupedApiVersionDescriptionProviderTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/GroupedApiVersionDescriptionProviderTest.cs @@ -19,7 +19,8 @@ public void api_version_descriptions_should_collate_expected_versions() new EndpointApiVersionMetadataCollationProvider( new TestEndpointDataSource() ), new ActionApiVersionMetadataCollationProvider( new TestActionDescriptorCollectionProvider() ), }, - Mock.Of(), + Mock.Of>(), + Mock.Of>(), Options.Create( new ApiExplorerOptions() { GroupNameFormat = "'v'VVV" } ) ); // act @@ -55,7 +56,8 @@ public void api_version_descriptions_should_collate_expected_versions_with_custo new EndpointApiVersionMetadataCollationProvider( source ), new ActionApiVersionMetadataCollationProvider( provider ), }, - Mock.Of(), + Mock.Of>(), + Mock.Of>(), Options.Create( new ApiExplorerOptions() { @@ -83,7 +85,7 @@ public void api_version_descriptions_should_apply_sunset_policy() // arrange var expected = new SunsetPolicy(); var apiVersion = new ApiVersion( 0.9 ); - var policyManager = new Mock(); + var policyManager = new Mock>(); policyManager.Setup( pm => pm.TryGetPolicy( default, apiVersion, out expected ) ).Returns( true ); @@ -94,6 +96,7 @@ public void api_version_descriptions_should_apply_sunset_policy() new ActionApiVersionMetadataCollationProvider( new TestActionDescriptorCollectionProvider() ), }, policyManager.Object, + Mock.Of>(), Options.Create( new ApiExplorerOptions() { GroupNameFormat = "'v'VVV" } ) ); // act diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/VersionedApiDescriptionProviderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/VersionedApiDescriptionProviderTest.cs index 8af044ae..cbe48584 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/VersionedApiDescriptionProviderTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/VersionedApiDescriptionProviderTest.cs @@ -17,7 +17,8 @@ public void versioned_api_explorer_should_group_and_order_descriptions_on_provid var actionProvider = new TestActionDescriptorCollectionProvider(); var context = new ApiDescriptionProviderContext( actionProvider.ActionDescriptors.Items ); var apiExplorer = new VersionedApiDescriptionProvider( - Mock.Of(), + Mock.Of>(), + Mock.Of>(), NewModelMetadataProvider(), Options.Create( new ApiExplorerOptions() { GroupNameFormat = "'v'VVV" } ) ); @@ -67,12 +68,13 @@ public void versioned_api_explorer_should_apply_sunset_policy() var context = new ApiDescriptionProviderContext( actionProvider.ActionDescriptors.Items ); var expected = new SunsetPolicy(); var apiVersion = new ApiVersion( 0.9 ); - var policyManager = new Mock(); + var policyManager = new Mock>(); policyManager.Setup( pm => pm.TryGetPolicy( default, apiVersion, out expected ) ).Returns( true ); var apiExplorer = new VersionedApiDescriptionProvider( policyManager.Object, + Mock.Of>(), NewModelMetadataProvider(), Options.Create( new ApiExplorerOptions() { GroupNameFormat = "'v'VVV" } ) ); @@ -102,7 +104,8 @@ public void versioned_api_explorer_should_preserve_group_name() var actionProvider = new TestActionDescriptorCollectionProvider( descriptor ); var context = new ApiDescriptionProviderContext( actionProvider.ActionDescriptors.Items ); var apiExplorer = new VersionedApiDescriptionProvider( - Mock.Of(), + Mock.Of>(), + Mock.Of>(), NewModelMetadataProvider(), Options.Create( new ApiExplorerOptions() ) ); @@ -132,7 +135,8 @@ public void versioned_api_explorer_should_use_custom_group_name() FormatGroupName = ( group, version ) => $"{group}-{version}", }; var apiExplorer = new VersionedApiDescriptionProvider( - Mock.Of(), + Mock.Of>(), + Mock.Of>(), NewModelMetadataProvider(), Options.Create( options ) ); @@ -213,7 +217,8 @@ public void versioned_api_explorer_should_prefer_explicit_over_implicit_action_m } ); var apiExplorer = new VersionedApiDescriptionProvider( - Mock.Of(), + Mock.Of>(), + Mock.Of>(), NewModelMetadataProvider(), Options.Create( new ApiExplorerOptions() { GroupNameFormat = "'v'VVV" } ) ); diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/ReportApiVersionsAttributeTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/ReportApiVersionsAttributeTest.cs index e06a3e0c..627b593a 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/ReportApiVersionsAttributeTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.Tests/ReportApiVersionsAttributeTest.cs @@ -84,7 +84,7 @@ private static ActionExecutingContext CreateContext( var controller = default( object ); var endpoint = new Endpoint( c => Task.CompletedTask, new( new[] { metadata } ), "Test" ); var options = Options.Create( new ApiVersioningOptions() ); - var reporter = new DefaultApiVersionReporter( new SunsetPolicyManager( options ) ); + var reporter = new DefaultApiVersionReporter( new SunsetPolicyManager( options ), new DeprecationPolicyManager( options ) ); endpointFeature.SetupProperty( f => f.Endpoint, endpoint ); versioningFeature.SetupProperty( f => f.RequestedApiVersion, new ApiVersion( 1.0 ) ); diff --git a/src/Client/src/Asp.Versioning.Http.Client/ApiInformation.cs b/src/Client/src/Asp.Versioning.Http.Client/ApiInformation.cs index cc75c6e5..53046eab 100644 --- a/src/Client/src/Asp.Versioning.Http.Client/ApiInformation.cs +++ b/src/Client/src/Asp.Versioning.Http.Client/ApiInformation.cs @@ -15,16 +15,19 @@ public class ApiInformation /// The supported read-only list of API versions. /// The deprecated read-only list of API versions. /// The API sunset policy. + /// The API deprecation policy. /// The read-only mapping of API version to OpenAPI document URLs. public ApiInformation( IReadOnlyList supportedVersions, IReadOnlyList deprecatedVersions, SunsetPolicy sunsetPolicy, + DeprecationPolicy deprecationPolicy, IReadOnlyDictionary openApiDocumentUrls ) { SupportedApiVersions = supportedVersions ?? throw new System.ArgumentNullException( nameof( supportedVersions ) ); DeprecatedApiVersions = deprecatedVersions ?? throw new System.ArgumentNullException( nameof( deprecatedVersions ) ); SunsetPolicy = sunsetPolicy ?? throw new System.ArgumentNullException( nameof( sunsetPolicy ) ); + DeprecationPolicy = deprecationPolicy ?? throw new System.ArgumentNullException( nameof( deprecationPolicy ) ); OpenApiDocumentUrls = openApiDocumentUrls ?? throw new System.ArgumentNullException( nameof( openApiDocumentUrls ) ); } @@ -33,6 +36,7 @@ private ApiInformation() SupportedApiVersions = Array.Empty(); DeprecatedApiVersions = Array.Empty(); SunsetPolicy = new(); + DeprecationPolicy = new(); OpenApiDocumentUrls = new Dictionary( capacity: 0 ); } @@ -62,6 +66,12 @@ private ApiInformation() /// The sunset policy for the API. public SunsetPolicy SunsetPolicy { get; } + /// + /// Gets the API deprecation policy. + /// + /// The deprecation policy for the API. + public DeprecationPolicy DeprecationPolicy { get; } + /// /// Gets the OpenAPI document URLs for each version. /// diff --git a/src/Client/src/Asp.Versioning.Http.Client/ApiNotificationContext.cs b/src/Client/src/Asp.Versioning.Http.Client/ApiNotificationContext.cs index 449e249d..27483f37 100644 --- a/src/Client/src/Asp.Versioning.Http.Client/ApiNotificationContext.cs +++ b/src/Client/src/Asp.Versioning.Http.Client/ApiNotificationContext.cs @@ -8,6 +8,7 @@ namespace Asp.Versioning.Http; public class ApiNotificationContext { private SunsetPolicy? sunsetPolicy; + private DeprecationPolicy? deprecationPolicy; /// /// Initializes a new instance of the class. @@ -20,6 +21,20 @@ public ApiNotificationContext( HttpResponseMessage response, ApiVersion apiVersi ApiVersion = apiVersion ?? throw new System.ArgumentNullException( nameof( apiVersion ) ); } + /// + /// Initializes a new instance of the class. + /// + /// The current HTTP response. + /// The requested API version. + /// The sunset policy which was previously read from the . + /// The deprecation policy which was previously read from the . + public ApiNotificationContext( HttpResponseMessage response, ApiVersion apiVersion, SunsetPolicy? sunsetPolicy = null, DeprecationPolicy? deprecationPolicy = null ) + : this( response, apiVersion ) + { + this.sunsetPolicy = sunsetPolicy; + this.deprecationPolicy = deprecationPolicy; + } + /// /// Gets the current HTTP response. /// @@ -37,4 +52,10 @@ public ApiNotificationContext( HttpResponseMessage response, ApiVersion apiVersi /// /// The reported API sunset policy. public SunsetPolicy SunsetPolicy => sunsetPolicy ??= Response.ReadSunsetPolicy(); + + /// + /// Gets the API deprecation policy reported in the response. + /// + /// The reported API deprecation policy. + public DeprecationPolicy DeprecationPolicy => deprecationPolicy ??= Response.ReadDeprecationPolicy(); } \ No newline at end of file diff --git a/src/Client/src/Asp.Versioning.Http.Client/ApiVersionHandler.cs b/src/Client/src/Asp.Versioning.Http.Client/ApiVersionHandler.cs index 5595251b..92abb261 100644 --- a/src/Client/src/Asp.Versioning.Http.Client/ApiVersionHandler.cs +++ b/src/Client/src/Asp.Versioning.Http.Client/ApiVersionHandler.cs @@ -46,10 +46,10 @@ protected override async Task SendAsync( HttpRequestMessage var response = await base.SendAsync( request, cancellationToken ).ConfigureAwait( false ); - if ( IsDeprecatedApi( response ) ) + if ( IsDeprecatedApi( response, out var deprecationPolicy ) ) { response.RequestMessage ??= request; - await notification.OnApiDeprecatedAsync( new( response, apiVersion ), cancellationToken ).ConfigureAwait( false ); + await notification.OnApiDeprecatedAsync( new( response, apiVersion, deprecationPolicy: deprecationPolicy ), cancellationToken ).ConfigureAwait( false ); } else if ( IsNewApiAvailable( response ) ) { @@ -64,11 +64,19 @@ protected override async Task SendAsync( HttpRequestMessage /// Determines whether the requested API is deprecated. /// /// The HTTP response from the requested API. + /// The deprecation policy read from the . /// True if the requested API has been deprecated; otherwise, false. - protected virtual bool IsDeprecatedApi( HttpResponseMessage response ) + protected virtual bool IsDeprecatedApi( HttpResponseMessage response, out DeprecationPolicy deprecationPolicy ) { ArgumentNullException.ThrowIfNull( response ); + deprecationPolicy = response.ReadDeprecationPolicy(); + + if ( deprecationPolicy.Date.HasValue && deprecationPolicy.Date <= DateTimeOffset.UtcNow ) + { + return true; + } + foreach ( var reportedApiVersion in enumerable.Deprecated( response, parser ) ) { // don't use '==' operator because a derived type may not overload it diff --git a/src/Client/src/Asp.Versioning.Http.Client/ApiVersionHeaderEnumerable.cs b/src/Client/src/Asp.Versioning.Http.Client/ApiVersionHeaderEnumerable.cs index 32dc0c69..8d07c2b2 100644 --- a/src/Client/src/Asp.Versioning.Http.Client/ApiVersionHeaderEnumerable.cs +++ b/src/Client/src/Asp.Versioning.Http.Client/ApiVersionHeaderEnumerable.cs @@ -42,7 +42,7 @@ public ApiVersionEnumerator Supported( new( response, apiSupportedVersionsName, parser ); /// - /// Creates and returns an enumerator for deprecated API versions. + /// Creates and returns an enumerator for deprecated API versions, as read from the api-deprecated-versions header. /// /// The HTTP response to evaluate. /// The optional API version parser. diff --git a/src/Client/src/Asp.Versioning.Http.Client/Asp.Versioning.Http.Client.csproj b/src/Client/src/Asp.Versioning.Http.Client/Asp.Versioning.Http.Client.csproj index 08fc140f..e04e62ca 100644 --- a/src/Client/src/Asp.Versioning.Http.Client/Asp.Versioning.Http.Client.csproj +++ b/src/Client/src/Asp.Versioning.Http.Client/Asp.Versioning.Http.Client.csproj @@ -33,6 +33,7 @@ + diff --git a/src/Client/src/Asp.Versioning.Http.Client/System.Net.Http/HttpClientExtensions.cs b/src/Client/src/Asp.Versioning.Http.Client/System.Net.Http/HttpClientExtensions.cs index b42c2d28..974a5db0 100644 --- a/src/Client/src/Asp.Versioning.Http.Client/System.Net.Http/HttpClientExtensions.cs +++ b/src/Client/src/Asp.Versioning.Http.Client/System.Net.Http/HttpClientExtensions.cs @@ -94,8 +94,9 @@ public static async Task GetApiInformationAsync( var deprecated = versions.ToArray(); var sunsetPolicy = response.ReadSunsetPolicy(); + var deprecationPolicy = response.ReadDeprecationPolicy(); var urls = response.GetOpenApiDocumentUrls( parser ); - return new( supported, deprecated, sunsetPolicy, urls ); + return new( supported, deprecated, sunsetPolicy, deprecationPolicy, urls ); } } \ No newline at end of file diff --git a/src/Client/src/Asp.Versioning.Http.Client/System.Net.Http/HttpResponseMessageExtensions.cs b/src/Client/src/Asp.Versioning.Http.Client/System.Net.Http/HttpResponseMessageExtensions.cs index 0a430a32..a072788c 100644 --- a/src/Client/src/Asp.Versioning.Http.Client/System.Net.Http/HttpResponseMessageExtensions.cs +++ b/src/Client/src/Asp.Versioning.Http.Client/System.Net.Http/HttpResponseMessageExtensions.cs @@ -15,8 +15,13 @@ namespace System.Net.Http; public static class HttpResponseMessageExtensions { private const string Sunset = nameof( Sunset ); + private const string Deprecation = nameof( Deprecation ); private const string Link = nameof( Link ); +#if NETSTANDARD1_1 + private static readonly DateTime UnixEpoch = new DateTime( 1970, 1, 1, 0, 0, 0, DateTimeKind.Utc ); +#endif + /// /// Gets an API sunset policy from the HTTP response. /// @@ -69,6 +74,87 @@ public static SunsetPolicy ReadSunsetPolicy( this HttpResponseMessage response ) return policy; } + /// + /// Formats the as required for a Deprecation header. + /// + /// The date when the api is deprecated. + /// A formatted string as required for a Deprecation header. + public static string ToDeprecationHeaderValue( this DateTimeOffset deprecationDate ) + { + var unixTimestamp = deprecationDate.ToUnixTimeSeconds(); + return unixTimestamp.ToString( "'@'0", CultureInfo.InvariantCulture ); + } + + /// + /// Gets an API deprecation policy from the HTTP response. + /// + /// The HTTP response to read from. + /// A new deprecation policy. + public static DeprecationPolicy ReadDeprecationPolicy( this HttpResponseMessage response ) + { + ArgumentNullException.ThrowIfNull( response ); + + var headers = response.Headers; + var date = default( DateTimeOffset ); + DeprecationPolicy policy; + + if ( headers.TryGetValues( Deprecation, out var values ) ) + { + var culture = CultureInfo.InvariantCulture; + var style = NumberStyles.Integer; + + foreach ( var value in values ) + { + if ( value.Length < 2 || value[0] != '@' ) + { + continue; + } + +#if NETSTANDARD + if ( long.TryParse( value.Substring( 1 ), style, culture, out var unixTimestamp ) ) +#else + if ( long.TryParse( value.AsSpan()[1..], style, culture, out var unixTimestamp ) ) +#endif + { + DateTimeOffset parsed; +#if NETSTANDARD1_1 + parsed = UnixEpoch + TimeSpan.FromSeconds( unixTimestamp ); +#else + parsed = DateTimeOffset.FromUnixTimeSeconds( unixTimestamp ); +#endif + + if ( date == default || date > parsed ) + { + date = parsed; + } + } + } + + policy = date == default ? new() : new( date ); + } + else + { + policy = new(); + } + + if ( headers.TryGetValues( Link, out values ) ) + { + var baseUrl = response.RequestMessage?.RequestUri; + Func resolver = baseUrl is null ? url => url : url => new( baseUrl, url ); + + foreach ( var value in values ) + { + if ( LinkHeaderValue.TryParse( value, resolver, out var link ) && + link.RelationType.Equals( "deprecation", OrdinalIgnoreCase ) ) + { + policy.Links.Add( link ); + } + } + } + + return policy; + } + /// /// Gets the OpenAPI document URLs from the HTTP response. /// diff --git a/src/Client/src/Asp.Versioning.Http.Client/net#.0/ApiVersionHandlerLogger{T}.cs b/src/Client/src/Asp.Versioning.Http.Client/net#.0/ApiVersionHandlerLogger{T}.cs index 4f8a8b86..818f360e 100644 --- a/src/Client/src/Asp.Versioning.Http.Client/net#.0/ApiVersionHandlerLogger{T}.cs +++ b/src/Client/src/Asp.Versioning.Http.Client/net#.0/ApiVersionHandlerLogger{T}.cs @@ -36,8 +36,9 @@ protected override void OnApiDeprecated( ApiNotificationContext context ) var requestUrl = context.Response.RequestMessage!.RequestUri!; var apiVersion = context.ApiVersion; var sunsetPolicy = context.SunsetPolicy; + var deprecationPolicy = context.DeprecationPolicy; - logger.ApiVersionDeprecated( requestUrl, apiVersion, sunsetPolicy ); + logger.ApiVersionDeprecated( requestUrl, apiVersion, sunsetPolicy, deprecationPolicy ); } /// diff --git a/src/Client/src/Asp.Versioning.Http.Client/net#.0/ILoggerExtensions.cs b/src/Client/src/Asp.Versioning.Http.Client/net#.0/ILoggerExtensions.cs index a7535807..841e086a 100644 --- a/src/Client/src/Asp.Versioning.Http.Client/net#.0/ILoggerExtensions.cs +++ b/src/Client/src/Asp.Versioning.Http.Client/net#.0/ILoggerExtensions.cs @@ -16,25 +16,33 @@ internal static void ApiVersionDeprecated( this ILogger logger, Uri requestUrl, ApiVersion apiVersion, - SunsetPolicy sunsetPolicy ) + SunsetPolicy sunsetPolicy, + DeprecationPolicy deprecationPolicy ) { var sunsetDate = FormatDate( sunsetPolicy.Date ); - var additionalInfo = FormatLinks( sunsetPolicy ); + var deprecationDate = FormatDate( deprecationPolicy.Date ); + + var additionalInfoSunset = FormatLinks( sunsetPolicy ); + var additionalInfoDeprecation = FormatLinks( deprecationPolicy ); + + var additionalInfo = additionalInfoDeprecation.Concat( additionalInfoSunset ).ToArray(); ApiVersionDeprecated( logger, apiVersion.ToString(), requestUrl.OriginalString, sunsetDate, + deprecationDate, additionalInfo ); } - [LoggerMessage( EventId = 1, Level = Warning, Message = "API version {apiVersion} for {requestUrl} has been deprecated and will sunset on {sunsetDate}. Additional information: {links}" )] + [LoggerMessage( EventId = 1, Level = Warning, Message = "API version {apiVersion} for {requestUrl} has been deprecated since {deprecationDate} and will sunset on {sunsetDate}. Additional information: {links}" )] static partial void ApiVersionDeprecated( ILogger logger, string apiVersion, string requestUrl, string sunsetDate, + string deprecationDate, string[] links ); [MethodImpl( MethodImplOptions.AggressiveInlining )] @@ -77,9 +85,23 @@ private static string[] FormatLinks( SunsetPolicy sunsetPolicy ) return []; } + return FormatLinks( sunsetPolicy.Links ); + } + + private static string[] FormatLinks( DeprecationPolicy deprecationPolicy ) + { + if ( !deprecationPolicy.HasLinks ) + { + return []; + } + + return FormatLinks( deprecationPolicy.Links ); + } + + private static string[] FormatLinks( IList links ) + { // (<Language>[,<Language>]): <Url> var text = new StringBuilder(); - var links = sunsetPolicy.Links; var additionalInfo = new string[links.Count]; for ( var i = 0; i < links.Count; i++ ) diff --git a/src/Client/test/Asp.Versioning.Http.Client.Tests/ApiVersionHandlerTest.cs b/src/Client/test/Asp.Versioning.Http.Client.Tests/ApiVersionHandlerTest.cs index 348b4886..fb87a691 100644 --- a/src/Client/test/Asp.Versioning.Http.Client.Tests/ApiVersionHandlerTest.cs +++ b/src/Client/test/Asp.Versioning.Http.Client.Tests/ApiVersionHandlerTest.cs @@ -25,7 +25,31 @@ public async Task send_async_should_write_api_version_to_request() } [Fact] - public async Task send_async_should_signal_deprecated_api_version() + public async Task send_async_should_not_notify_when_no_headers_are_set() + { + // arrange + var writer = Mock.Of<IApiVersionWriter>(); + var notification = Mock.Of<IApiNotification>(); + var request = new HttpRequestMessage( HttpMethod.Get, "http://tempuri.org" ); + var version = new ApiVersion( 1.0 ); + using var handler = new ApiVersionHandler( writer, version, notification ) + { + InnerHandler = new TestServer(), + }; + using var invoker = new HttpMessageInvoker( handler ); + + // act + await invoker.SendAsync( request, default ); + + // assert + Mock.Get( notification ) + .Verify( n => n.OnApiDeprecatedAsync( It.IsAny<ApiNotificationContext>(), It.IsAny<CancellationToken>() ), Times.Never ); + Mock.Get( notification ) + .Verify( n => n.OnNewApiAvailableAsync( It.IsAny<ApiNotificationContext>(), It.IsAny<CancellationToken>() ), Times.Never ); + } + + [Fact] + public async Task send_async_should_signal_deprecated_api_versions_from_header() { // arrange var writer = Mock.Of<IApiVersionWriter>(); @@ -50,6 +74,32 @@ public async Task send_async_should_signal_deprecated_api_version() .Verify( n => n.OnApiDeprecatedAsync( It.IsAny<ApiNotificationContext>(), default ) ); } + [Fact] + public async Task send_async_should_signal_deprecated_api_versions_from_deprecation_policy() + { + // arrange + var writer = Mock.Of<IApiVersionWriter>(); + var notification = Mock.Of<IApiNotification>(); + var request = new HttpRequestMessage( HttpMethod.Get, "http://tempuri.org" ); + var response = new HttpResponseMessage(); + var version = new ApiVersion( 1.0 ); + using var handler = new ApiVersionHandler( writer, version, notification ) + { + InnerHandler = new TestServer( response ), + }; + using var invoker = new HttpMessageInvoker( handler ); + + response.Headers.Add( "api-supported-versions", "2.0" ); + response.Headers.Add( "Deprecation", DateTimeOffset.UtcNow.ToDeprecationHeaderValue() ); + + // act + await invoker.SendAsync( request, default ); + + // assert + Mock.Get( notification ) + .Verify( n => n.OnApiDeprecatedAsync( It.IsAny<ApiNotificationContext>(), default ) ); + } + [Fact] public async Task send_async_should_signal_new_api_version() { diff --git a/src/Client/test/Asp.Versioning.Http.Client.Tests/System.Net.Http/HttpClientExtensionsTest.cs b/src/Client/test/Asp.Versioning.Http.Client.Tests/System.Net.Http/HttpClientExtensionsTest.cs index 6ac1890a..b64a396e 100644 --- a/src/Client/test/Asp.Versioning.Http.Client.Tests/System.Net.Http/HttpClientExtensionsTest.cs +++ b/src/Client/test/Asp.Versioning.Http.Client.Tests/System.Net.Http/HttpClientExtensionsTest.cs @@ -41,6 +41,7 @@ public async Task get_api_information_async_should_return_expected_result() { Type = "text/html", } ), + new(), new Dictionary<ApiVersion, Uri>() { [new( 1.0 )] = new( "http://tempuri.org/swagger/v1/swagger.json" ) } ) ); } } \ No newline at end of file diff --git a/src/Client/test/Asp.Versioning.Http.Client.Tests/System.Net.Http/HttpResponseMessageExtensionsTest.cs b/src/Client/test/Asp.Versioning.Http.Client.Tests/System.Net.Http/HttpResponseMessageExtensionsTest.cs index bb4ffb45..ef10b244 100644 --- a/src/Client/test/Asp.Versioning.Http.Client.Tests/System.Net.Http/HttpResponseMessageExtensionsTest.cs +++ b/src/Client/test/Asp.Versioning.Http.Client.Tests/System.Net.Http/HttpResponseMessageExtensionsTest.cs @@ -10,7 +10,7 @@ public class HttpResponseMessageExtensionsTest public void read_sunset_policy_should_parse_response() { // arrange - var date = DateTimeOffset.Now; + var date = DateTimeOffset.UtcNow; var request = new HttpRequestMessage( HttpMethod.Get, "http://tempuri.org" ); var response = new HttpResponseMessage() { RequestMessage = request }; @@ -35,7 +35,7 @@ public void read_sunset_policy_should_parse_response() public void read_sunset_policy_should_use_greatest_date() { // arrange - var date = DateTimeOffset.Now; + var date = DateTimeOffset.UtcNow; var expected = date.AddDays( 14 ); var request = new HttpRequestMessage( HttpMethod.Get, "http://tempuri.org" ); var response = new HttpResponseMessage() { RequestMessage = request }; @@ -87,6 +87,87 @@ public void read_sunset_policy_should_ignore_unrelated_links() } ); } + [Fact] + public void read_deprecation_policy_should_parse_response() + { + // arrange + var date = DateTimeOffset.UtcNow; + var request = new HttpRequestMessage( HttpMethod.Get, "http://tempuri.org" ); + var response = new HttpResponseMessage() { RequestMessage = request }; + + response.Headers.Add( "deprecation", date.ToDeprecationHeaderValue() ); + response.Headers.Add( "link", "<policy>; rel=\"deprecation\"; type=\"text/html\"" ); + + // act + var policy = response.ReadDeprecationPolicy(); + + // assert + policy.Date.Value.ToLocalTime().Should().BeCloseTo( date, TimeSpan.FromSeconds( 2d ) ); + policy.Links.Single().Should().BeEquivalentTo( + new LinkHeaderValue( + new Uri( "http://tempuri.org/policy" ), + "deprecation" ) + { + Type = "text/html", + } ); + } + + [Fact] + public void read_deprecation_policy_should_use_smallest_date() + { + // arrange + var date = DateTimeOffset.UtcNow; + var expected = date.Subtract( TimeSpan.FromDays( 14 ) ); + var request = new HttpRequestMessage( HttpMethod.Get, "http://tempuri.org" ); + var response = new HttpResponseMessage() { RequestMessage = request }; + + response.Headers.Add( + "deprecation", + new string[] + { + date.ToDeprecationHeaderValue(), + expected.ToDeprecationHeaderValue(), + expected.AddDays( 3 ).ToDeprecationHeaderValue(), + } ); + + // act + var policy = response.ReadDeprecationPolicy(); + + // assert + policy.Date.Value.ToLocalTime().Should().BeCloseTo( expected, TimeSpan.FromSeconds( 2d ) ); + policy.HasLinks.Should().BeFalse(); + } + + [Fact] + public void read_deprecation_policy_should_ignore_unrelated_links() + { + // arrange + var request = new HttpRequestMessage( HttpMethod.Get, "http://tempuri.org" ); + var response = new HttpResponseMessage() { RequestMessage = request }; + + response.Headers.Add( + "link", + new[] + { + "<swagger.json>; rel=\"openapi\"; type=\"application/json\" title=\"OpenAPI\"", + "<policy>; rel=\"deprecation\"; type=\"text/html\"", + "<docs>; rel=\"info\"; type=\"text/html\" title=\"Documentation\"", + } ); + + // act + var policy = response.ReadDeprecationPolicy(); + + // assert + policy.Date.Should().BeNull(); + policy.Links.Single().Should().BeEquivalentTo( + new LinkHeaderValue( + new Uri( "http://tempuri.org/policy" ), + "deprecation" ) + { + Type = "text/html", + } ); + } + [Fact] public void get_open_api_document_urls_should_return_expected_values() { diff --git a/src/Client/test/Asp.Versioning.Http.Client.Tests/net#.0/ApiVersionHandlerLoggerTTest.cs b/src/Client/test/Asp.Versioning.Http.Client.Tests/net#.0/ApiVersionHandlerLoggerTTest.cs index 7662f2ce..510dec39 100644 --- a/src/Client/test/Asp.Versioning.Http.Client.Tests/net#.0/ApiVersionHandlerLoggerTTest.cs +++ b/src/Client/test/Asp.Versioning.Http.Client.Tests/net#.0/ApiVersionHandlerLoggerTTest.cs @@ -21,10 +21,10 @@ public async Task on_api_deprecated_should_log_message() }; var context = new ApiNotificationContext( response, new ApiVersion( 1.0 ) ); var date = DateTimeOffset.Now; - var expected = "API version 1.0 for http://tempuri.org has been deprecated and will " + + var expected = "API version 1.0 for http://tempuri.org has been deprecated since <unspecified> and will " + $"sunset on {date.ToUniversalTime()}. Additional information: " + - "API Policy (en): http://tempuri.org/policy/en, " + - "API Política (es): http://tempuri.org/policy/es"; + "[API Policy (en): http://tempuri.org/policy/en, " + + "API Política (es): http://tempuri.org/policy/es]"; response.Headers.Add( "sunset", date.ToString( "r" ) ); response.Headers.Add( "link", "<policy/en>; rel=\"sunset\"; type=\"text/html\"; title=\"API Policy\"; hreflang=\"en\"" ); diff --git a/src/Common/src/Common.Backport/DateTimeOffsetExtensions.cs b/src/Common/src/Common.Backport/DateTimeOffsetExtensions.cs new file mode 100644 index 00000000..724e6525 --- /dev/null +++ b/src/Common/src/Common.Backport/DateTimeOffsetExtensions.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace System; + +internal static class DateTimeOffsetExtensions +{ + private const long UnixEpochSeconds = 62_135_596_800L; + + // REF: https://github.com/dotnet/dotnet/blob/main/src/runtime/src/libraries/System.Private.CoreLib/src/System/DateTimeOffset.cs#L745 + public static long ToUnixTimeSeconds( this DateTimeOffset dateTimeOffset ) + { + var seconds = (long) ( (ulong) dateTimeOffset.UtcTicks / TimeSpan.TicksPerSecond ); + return seconds - UnixEpochSeconds; + } +} \ No newline at end of file diff --git a/src/Common/src/Common/ApiVersioningPolicyBuilder.cs b/src/Common/src/Common/ApiVersioningPolicyBuilder.cs index 2eb50a29..55687f82 100644 --- a/src/Common/src/Common/ApiVersioningPolicyBuilder.cs +++ b/src/Common/src/Common/ApiVersioningPolicyBuilder.cs @@ -10,6 +10,7 @@ namespace Asp.Versioning; public class ApiVersioningPolicyBuilder : IApiVersioningPolicyBuilder { private Dictionary<PolicyKey, ISunsetPolicyBuilder>? sunsetPolicies; + private Dictionary<PolicyKey, IDeprecationPolicyBuilder>? deprecationPolicies; /// <inheritdoc /> public virtual IReadOnlyList<T> OfType<T>() where T : notnull @@ -18,6 +19,10 @@ public virtual IReadOnlyList<T> OfType<T>() where T : notnull { return ( sunsetPolicies.Values.ToArray() as IReadOnlyList<T> )!; } + else if ( typeof( T ) == typeof( IDeprecationPolicyBuilder ) && deprecationPolicies != null ) + { + return ( deprecationPolicies.Values.ToArray() as IReadOnlyList<T> )!; + } return Array.Empty<T>(); } @@ -42,4 +47,25 @@ public virtual ISunsetPolicyBuilder Sunset( string? name, ApiVersion? apiVersion return builder; } + + /// <inheritdoc /> + public virtual IDeprecationPolicyBuilder Deprecate( string? name, ApiVersion? apiVersion ) + { + if ( string.IsNullOrEmpty( name ) && apiVersion == null ) + { + var message = string.Format( CultureInfo.CurrentCulture, Format.InvalidPolicyKey, nameof( name ), nameof( apiVersion ) ); + throw new System.ArgumentException( message ); + } + + var key = new PolicyKey( name, apiVersion ); + + deprecationPolicies ??= []; + + if ( !deprecationPolicies.TryGetValue( key, out var builder ) ) + { + deprecationPolicies.Add( key, builder = new DeprecationPolicyBuilder( name, apiVersion ) ); + } + + return builder; + } } \ No newline at end of file diff --git a/src/Common/src/Common/DefaultApiVersionReporter.cs b/src/Common/src/Common/DefaultApiVersionReporter.cs index 72ed1408..84c71984 100644 --- a/src/Common/src/Common/DefaultApiVersionReporter.cs +++ b/src/Common/src/Common/DefaultApiVersionReporter.cs @@ -22,14 +22,16 @@ public sealed partial class DefaultApiVersionReporter : IReportApiVersions private const string ApiDeprecatedVersions = "api-deprecated-versions"; private const string Sunset = nameof( Sunset ); private const string Link = nameof( Link ); - private readonly ISunsetPolicyManager sunsetPolicyManager; + private readonly IPolicyManager<SunsetPolicy> sunsetPolicyManager; + private readonly IPolicyManager<DeprecationPolicy> deprecationPolicyManager; private readonly string apiSupportedVersionsName; private readonly string apiDeprecatedVersionsName; /// <summary> /// Initializes a new instance of the <see cref="DefaultApiVersionReporter"/> class. /// </summary> - /// <param name="sunsetPolicyManager">The <see cref="ISunsetPolicyManager">manager</see> used to resolve sunset policies.</param> + /// <param name="sunsetPolicyManager">The <see cref="IPolicyManager{TPolicy}">manager</see> used to resolve sunset policies.</param> + /// <param name="deprecationPolicyManager">The <see cref="IPolicyManager{TPolicy}">manager</see> used to resolve deprecation policies.</param> /// <param name="supportedHeaderName">The HTTP header name used for supported API versions. /// The default value is "api-supported-versions".</param> /// <param name="deprecatedHeaderName">THe HTTP header name used for deprecated API versions. @@ -37,7 +39,8 @@ public sealed partial class DefaultApiVersionReporter : IReportApiVersions /// <param name="mapping">One or more of API versioning mappings. The default value is /// <see cref="ApiVersionMapping.Explicit"/> and <see cref="ApiVersionMapping.Implicit"/>.</param> public DefaultApiVersionReporter( - ISunsetPolicyManager sunsetPolicyManager, + IPolicyManager<SunsetPolicy> sunsetPolicyManager, + IPolicyManager<DeprecationPolicy> deprecationPolicyManager, string supportedHeaderName = ApiSupportedVersions, string deprecatedHeaderName = ApiDeprecatedVersions, ApiVersionMapping mapping = Explicit | Implicit ) @@ -47,6 +50,7 @@ public DefaultApiVersionReporter( ArgumentException.ThrowIfNullOrEmpty( deprecatedHeaderName ); this.sunsetPolicyManager = sunsetPolicyManager; + this.deprecationPolicyManager = deprecationPolicyManager; apiSupportedVersionsName = supportedHeaderName; apiDeprecatedVersionsName = deprecatedHeaderName; Mapping = mapping; @@ -90,10 +94,21 @@ public void Report( HttpResponse response, ApiVersionModel apiVersionModel ) var version = context.GetRequestedApiVersion(); #endif var name = metadata.Name; + DateTimeOffset? sunsetDate = null; - if ( sunsetPolicyManager.TryResolvePolicy( name, version, out var policy ) ) + if ( sunsetPolicyManager.TryResolvePolicy( name, version, out var sunsetPolicy ) ) { - response.WriteSunsetPolicy( policy ); + sunsetDate = sunsetPolicy.Date; + response.WriteSunsetPolicy( sunsetPolicy ); + } + + if ( deprecationPolicyManager.TryResolvePolicy( name, version, out var deprecationPolicy ) ) + { + // Only emit a deprecation header if the deprecation policy becomes effective before the sunset date. + if ( deprecationPolicy.IsEffective( sunsetDate ) ) + { + response.WriteDeprecationPolicy( deprecationPolicy ); + } } } } \ No newline at end of file diff --git a/src/Common/src/Common/DeprecationPolicyBuilder.cs b/src/Common/src/Common/DeprecationPolicyBuilder.cs new file mode 100644 index 00000000..ca80bb2e --- /dev/null +++ b/src/Common/src/Common/DeprecationPolicyBuilder.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// <summary> +/// Represents the default deprecation policy builder. +/// </summary> +public class DeprecationPolicyBuilder : PolicyBuilder<DeprecationPolicy>, IDeprecationPolicyBuilder +{ + private DateTimeOffset? date; + private DeprecationLinkBuilder? linkBuilder; + private Dictionary<Uri, DeprecationLinkBuilder>? linkBuilders; + + /// <summary> + /// Initializes a new instance of the <see cref="DeprecationPolicyBuilder"/> class. + /// </summary> + /// <param name="name">The name of the API the policy is for.</param> + /// <param name="apiVersion">The <see cref="ApiVersion">API version</see> the policy is for.</param> + public DeprecationPolicyBuilder( string? name, ApiVersion? apiVersion ) + : base( name, apiVersion ) { } + + /// <inheritdoc /> + public virtual void SetEffectiveDate( DateTimeOffset effectiveDate ) + { + date = effectiveDate; + } + + /// <inheritdoc /> + public virtual ILinkBuilder Link( Uri linkTarget ) + { + DeprecationLinkBuilder newLinkBuilder; + + if ( linkBuilder == null ) + { + linkBuilder = newLinkBuilder = new( this, linkTarget ); + } + else if ( linkBuilder.LinkTarget.Equals( linkTarget ) ) + { + return linkBuilder; + } + else if ( linkBuilders == null ) + { + linkBuilders = new() + { + [linkBuilder.LinkTarget] = linkBuilder, + [linkTarget] = newLinkBuilder = new( this, linkTarget ), + }; + } + else if ( !linkBuilders.TryGetValue( linkTarget, out newLinkBuilder! ) ) + { + linkBuilders.Add( linkTarget, newLinkBuilder = new( this, linkTarget ) ); + } + + return newLinkBuilder; + } + + /// <inheritdoc /> + public override DeprecationPolicy Build() + { + if ( Policy is not null ) + { + return Policy; + } + + DeprecationPolicy policy = date is null ? new() : new( date.Value ); + + if ( linkBuilders == null ) + { + if ( linkBuilder != null ) + { + policy.Links.Add( linkBuilder.Build() ); + } + } + else + { + foreach ( var builder in linkBuilders.Values ) + { + policy.Links.Add( builder.Build() ); + } + } + + return policy; + } + + private sealed class DeprecationLinkBuilder : LinkBuilder, ILinkBuilder + { + protected override string RelationType => "deprecation"; + + private readonly DeprecationPolicyBuilder policyBuilder; + + public DeprecationLinkBuilder( DeprecationPolicyBuilder policy, Uri linkTarget ) + : base( linkTarget ) => policyBuilder = policy; + + public override ILinkBuilder Link( Uri linkTarget ) => policyBuilder.Link( linkTarget ); + } +} \ No newline at end of file diff --git a/src/Common/src/Common/DeprecationPolicyManager.cs b/src/Common/src/Common/DeprecationPolicyManager.cs new file mode 100644 index 00000000..3c8765da --- /dev/null +++ b/src/Common/src/Common/DeprecationPolicyManager.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// <summary> +/// Represents the default API version deprecation policy manager. +/// </summary> +/// <remarks> +/// This class serves as a type alias to hide the generic arguments of <see cref="PolicyManager{TPolicy, TPolicyBuilder}"/>. +/// </remarks> +public partial class DeprecationPolicyManager : PolicyManager<DeprecationPolicy, IDeprecationPolicyBuilder> +{ } \ No newline at end of file diff --git a/src/Common/src/Common/SunsetLinkBuilder.cs b/src/Common/src/Common/LinkBuilder.cs similarity index 80% rename from src/Common/src/Common/SunsetLinkBuilder.cs rename to src/Common/src/Common/LinkBuilder.cs index 03f72175..16075de3 100644 --- a/src/Common/src/Common/SunsetLinkBuilder.cs +++ b/src/Common/src/Common/LinkBuilder.cs @@ -2,17 +2,16 @@ namespace Asp.Versioning; -internal sealed class SunsetLinkBuilder : ILinkBuilder +internal abstract class LinkBuilder : ILinkBuilder { - private readonly SunsetPolicyBuilder policy; + protected abstract string RelationType { get; } private string? language; private List<string>? languages; private string? title; private string? type; - public SunsetLinkBuilder( SunsetPolicyBuilder policy, Uri linkTarget ) + public LinkBuilder( Uri linkTarget ) { - this.policy = policy; LinkTarget = linkTarget; } @@ -36,8 +35,6 @@ public ILinkBuilder Language( string value ) return this; } - public ILinkBuilder Link( Uri linkTarget ) => policy.Link( linkTarget ); - public ILinkBuilder Title( string value ) { title = value; @@ -50,9 +47,11 @@ public ILinkBuilder Type( string value ) return this; } + public abstract ILinkBuilder Link( Uri linkTarget ); + public LinkHeaderValue Build() { - var link = new LinkHeaderValue( LinkTarget, "sunset" ); + var link = new LinkHeaderValue( LinkTarget, RelationType ); if ( title != null ) { diff --git a/src/Common/src/Common/PolicyBuilder.cs b/src/Common/src/Common/PolicyBuilder.cs new file mode 100644 index 00000000..a493d906 --- /dev/null +++ b/src/Common/src/Common/PolicyBuilder.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using System.Globalization; + +/// <summary> +/// Represents the default policy builder. +/// </summary> +/// <typeparam name="TPolicy">The type of policy.</typeparam> +public abstract class PolicyBuilder<TPolicy> : IPolicyBuilder<TPolicy> +{ + /// <summary> + /// Gets a pre-built policy. + /// </summary> + /// <value>The pre-built policy, if it exists.</value> + protected TPolicy? Policy { get; private set; } + + /// <summary> + /// Initializes a new instance of the <see cref="PolicyBuilder{T}"/> class. + /// </summary> + /// <param name="name">The name of the API the policy is for.</param> + /// <param name="apiVersion">The <see cref="ApiVersion">API version</see> the policy is for.</param> + protected PolicyBuilder( string? name, ApiVersion? apiVersion ) + { + if ( string.IsNullOrEmpty( name ) && apiVersion == null ) + { + var message = string.Format( CultureInfo.CurrentCulture, Format.InvalidPolicyKey, nameof( name ), nameof( apiVersion ) ); + throw new System.ArgumentException( message ); + } + + Name = name; + ApiVersion = apiVersion; + } + + /// <inheritdoc /> + public string? Name { get; } + + /// <inheritdoc /> + public ApiVersion? ApiVersion { get; } + + /// <inheritdoc /> + public virtual void Per( TPolicy policy ) => + Policy = policy ?? throw new System.ArgumentNullException( nameof( policy ) ); + + /// <inheritdoc /> + public abstract TPolicy Build(); +} \ No newline at end of file diff --git a/src/Common/src/Common/PolicyManager.cs b/src/Common/src/Common/PolicyManager.cs new file mode 100644 index 00000000..ee06951f --- /dev/null +++ b/src/Common/src/Common/PolicyManager.cs @@ -0,0 +1,52 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +/// <inheritdoc/> +public abstract class PolicyManager<TPolicy, TPolicyBuilder> : IPolicyManager<TPolicy> + where TPolicyBuilder : IPolicyBuilder<TPolicy> +{ + private Dictionary<PolicyKey, TPolicy>? policies; + + /// <summary> + /// Gets the current api versioning options. + /// </summary> + /// <value>The api versioning options.</value> + protected abstract ApiVersioningOptions Options { get; } + + /// <inheritdoc /> + public virtual bool TryGetPolicy( + string? name, + ApiVersion? apiVersion, + [MaybeNullWhen( false )] out TPolicy policy ) + { + if ( string.IsNullOrEmpty( name ) && apiVersion == null ) + { + policy = default!; + return false; + } + + policies ??= BuildPolicies( Options ); + + var key = new PolicyKey( name, apiVersion ); + + return policies.TryGetValue( key, out policy ); + } + + private static Dictionary<PolicyKey, TPolicy> BuildPolicies( ApiVersioningOptions options ) + { + var builders = options.Policies.OfType<TPolicyBuilder>(); + var mapping = new Dictionary<PolicyKey, TPolicy>( capacity: builders.Count ); + + for ( var i = 0; i < builders.Count; i++ ) + { + var builder = builders[i]; + var policy = builder.Build(); + var key = new PolicyKey( builder.Name, builder.ApiVersion ); + + mapping[key] = policy; + } + + return mapping; + } +} \ No newline at end of file diff --git a/src/Common/src/Common/SunsetPolicyBuilder.cs b/src/Common/src/Common/SunsetPolicyBuilder.cs index a6450b49..dc189696 100644 --- a/src/Common/src/Common/SunsetPolicyBuilder.cs +++ b/src/Common/src/Common/SunsetPolicyBuilder.cs @@ -2,14 +2,11 @@ namespace Asp.Versioning; -using System.Globalization; - /// <summary> /// Represents the default sunset policy builder. /// </summary> -public class SunsetPolicyBuilder : ISunsetPolicyBuilder +public class SunsetPolicyBuilder : PolicyBuilder<SunsetPolicy>, ISunsetPolicyBuilder { - private SunsetPolicy? sunsetPolicy; private DateTimeOffset? date; private SunsetLinkBuilder? linkBuilder; private Dictionary<Uri, SunsetLinkBuilder>? linkBuilders; @@ -20,32 +17,12 @@ public class SunsetPolicyBuilder : ISunsetPolicyBuilder /// <param name="name">The name of the API the policy is for.</param> /// <param name="apiVersion">The <see cref="ApiVersion">API version</see> the policy is for.</param> public SunsetPolicyBuilder( string? name, ApiVersion? apiVersion ) - { - if ( string.IsNullOrEmpty( name ) && apiVersion == null ) - { - var message = string.Format( CultureInfo.CurrentCulture, Format.InvalidPolicyKey, nameof( name ), nameof( apiVersion ) ); - throw new System.ArgumentException( message ); - } - - Name = name; - ApiVersion = apiVersion; - } - - /// <inheritdoc /> - public string? Name { get; } - - /// <inheritdoc /> - public ApiVersion? ApiVersion { get; } - - /// <inheritdoc /> - public virtual void Per( SunsetPolicy policy ) => - sunsetPolicy = policy ?? throw new System.ArgumentNullException( nameof( policy ) ); + : base( name, apiVersion ) { } /// <inheritdoc /> - public virtual ISunsetPolicyBuilder Effective( DateTimeOffset sunsetDate ) + public virtual void SetEffectiveDate( DateTimeOffset effectiveDate ) { - date = sunsetDate; - return this; + date = effectiveDate; } /// <inheritdoc /> @@ -78,11 +55,11 @@ public virtual ILinkBuilder Link( Uri linkTarget ) } /// <inheritdoc /> - public virtual SunsetPolicy Build() + public override SunsetPolicy Build() { - if ( sunsetPolicy is not null ) + if ( Policy is not null ) { - return sunsetPolicy; + return Policy; } SunsetPolicy policy = date is null ? new() : new( date.Value ); @@ -104,4 +81,16 @@ public virtual SunsetPolicy Build() return policy; } + + private sealed class SunsetLinkBuilder : LinkBuilder, ILinkBuilder + { + protected override string RelationType => "sunset"; + + private readonly SunsetPolicyBuilder policyBuilder; + + public SunsetLinkBuilder( SunsetPolicyBuilder policy, Uri linkTarget ) + : base( linkTarget ) => policyBuilder = policy; + + public override ILinkBuilder Link( Uri linkTarget ) => policyBuilder.Link( linkTarget ); + } } \ No newline at end of file diff --git a/src/Common/src/Common/SunsetPolicyManager.cs b/src/Common/src/Common/SunsetPolicyManager.cs index 50fdf115..9cfe0c73 100644 --- a/src/Common/src/Common/SunsetPolicyManager.cs +++ b/src/Common/src/Common/SunsetPolicyManager.cs @@ -5,46 +5,8 @@ namespace Asp.Versioning; /// <summary> /// Represents the default API version sunset policy manager. /// </summary> -public partial class SunsetPolicyManager : ISunsetPolicyManager -{ - private Dictionary<PolicyKey, SunsetPolicy>? policies; - - /// <inheritdoc /> - public virtual bool TryGetPolicy( - string? name, - ApiVersion? apiVersion, - [MaybeNullWhen( false )] out SunsetPolicy sunsetPolicy ) - { - if ( string.IsNullOrEmpty( name ) && apiVersion == null ) - { - sunsetPolicy = default!; - return false; - } - -#if NETFRAMEWORK - policies ??= BuildPolicies( options ); -#else - policies ??= BuildPolicies( options.Value ); -#endif - var key = new PolicyKey( name, apiVersion ); - - return policies.TryGetValue( key, out sunsetPolicy ); - } - - private static Dictionary<PolicyKey, SunsetPolicy> BuildPolicies( ApiVersioningOptions options ) - { - var builders = options.Policies.OfType<ISunsetPolicyBuilder>(); - var mapping = new Dictionary<PolicyKey, SunsetPolicy>( capacity: builders.Count ); - - for ( var i = 0; i < builders.Count; i++ ) - { - var builder = builders[i]; - var policy = builder.Build(); - var key = new PolicyKey( builder.Name, builder.ApiVersion ); - - mapping[key] = policy; - } - - return mapping; - } -} \ No newline at end of file +/// <remarks> +/// This class serves as a type alias to hide the generic arguments of <see cref="PolicyManager{TPolicy, TPolicyBuilder}"/>. +/// </remarks> +public partial class SunsetPolicyManager : PolicyManager<SunsetPolicy, ISunsetPolicyBuilder> +{ } \ No newline at end of file diff --git a/src/Common/test/Common.Tests/ApiVersioningPolicyBuilderTest.cs b/src/Common/test/Common.Tests/ApiVersioningPolicyBuilderTest.cs index 727c9060..82f0839e 100644 --- a/src/Common/test/Common.Tests/ApiVersioningPolicyBuilderTest.cs +++ b/src/Common/test/Common.Tests/ApiVersioningPolicyBuilderTest.cs @@ -36,6 +36,38 @@ public void sunset_should_return_same_policy_builder( string name, double? versi result.Should().BeSameAs( expected ); } + [Fact] + public void deprecate_should_not_allow_empty_name_and_version() + { + // arrange + var builder = new ApiVersioningPolicyBuilder(); + + // act + Func<IDeprecationPolicyBuilder> deprecation = () => builder.Deprecate( default, default ); + + // assert + deprecation.Should().Throw<ArgumentException>().And + .Message.Should().Be( "'name' and 'apiVersion' cannot both be null." ); + } + + [Theory] + [InlineData( "Test", null )] + [InlineData( null, 1.1 )] + [InlineData( "Test", 1.1 )] + public void deprecate_should_return_same_policy_builder( string name, double? version ) + { + // arrange + var apiVersion = version is null ? default : new ApiVersion( version.Value ); + var builder = new ApiVersioningPolicyBuilder(); + var expected = builder.Deprecate( name, apiVersion ); + + // act + var result = builder.Deprecate( name, apiVersion ); + + // assert + result.Should().BeSameAs( expected ); + } + [Fact] public void of_type_should_return_empty_list_for_unknown_type() { @@ -50,16 +82,34 @@ public void of_type_should_return_empty_list_for_unknown_type() } [Fact] - public void of_type_should_return_filtered_builders() + public void of_type_sunset_should_return_filtered_builders() { // arrange var builder = new ApiVersioningPolicyBuilder(); var expected = builder.Sunset( default, ApiVersion.Default ); + var deprecation = builder.Deprecate( default, ApiVersion.Default ); // act var list = builder.OfType<ISunsetPolicyBuilder>(); // assert list.Single().Should().BeSameAs( expected ); + list.Single().Should().NotBeSameAs( deprecation ); + } + + [Fact] + public void of_type_deprecation_should_return_filtered_builders() + { + // arrange + var builder = new ApiVersioningPolicyBuilder(); + var sunset = builder.Sunset( default, ApiVersion.Default ); + var expected = builder.Deprecate( default, ApiVersion.Default ); + + // act + var list = builder.OfType<IDeprecationPolicyBuilder>(); + + // assert + list.Single().Should().BeSameAs( expected ); + list.Single().Should().NotBeSameAs( sunset ); } } \ No newline at end of file diff --git a/src/Common/test/Common.Tests/DeprecationPolicyBuilderTest.cs b/src/Common/test/Common.Tests/DeprecationPolicyBuilderTest.cs new file mode 100644 index 00000000..778fa5f8 --- /dev/null +++ b/src/Common/test/Common.Tests/DeprecationPolicyBuilderTest.cs @@ -0,0 +1,71 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +public class DeprecationPolicyBuilderTest +{ + [Fact] + public void constructor_should_not_allow_empty_name_and_version() + { + // arrange + + + // act + Func<DeprecationPolicyBuilder> @new = () => new DeprecationPolicyBuilder( default, default ); + + // assert + @new.Should().Throw<ArgumentException>().And + .Message.Should().Be( "'name' and 'apiVersion' cannot both be null." ); + } + + [Fact] + public void per_should_set_existing_immutable_policy() + { + // arrange + var builder = new DeprecationPolicyBuilder( default, ApiVersion.Default ); + var policy = new DeprecationPolicy(); + + // act + builder.Per( policy ); + builder.Link( "http://tempuri.org" ); + + var result = builder.Build(); + + // assert + result.Should().BeSameAs( policy ); + policy.HasLinks.Should().BeFalse(); + } + + [Fact] + public void link_should_should_return_existing_builder() + { + // arrange + var builder = new DeprecationPolicyBuilder( default, ApiVersion.Default ); + var expected = builder.Link( "http://tempuri.org" ); + + // act + var result = builder.Link( "http://tempuri.org" ); + + // assert + result.Should().BeSameAs( expected ); + } + + [Fact] + public void build_should_construct_deprecation_policy() + { + // arrange + var builder = new DeprecationPolicyBuilder( default, ApiVersion.Default ); + + builder.Effective( 2022, 2, 1 ) + .Link( "http://tempuri.org" ); + + // act + var policy = builder.Build(); + + // assert + policy.Should().BeEquivalentTo( + new DeprecationPolicy( + new DateTimeOffset( new DateTime( 2022, 2, 1 ) ), + new LinkHeaderValue( new Uri( "http://tempuri.org" ), "deprecation" ) ) ); + } +} \ No newline at end of file diff --git a/src/Common/test/Common.Tests/DeprecationPolicyManagerTest.cs b/src/Common/test/Common.Tests/DeprecationPolicyManagerTest.cs new file mode 100644 index 00000000..90994d5f --- /dev/null +++ b/src/Common/test/Common.Tests/DeprecationPolicyManagerTest.cs @@ -0,0 +1,64 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +#if !NETFRAMEWORK +using Microsoft.Extensions.Options; +#endif + +public class DeprecationPolicyManagerTest +{ + [Fact] + public void try_get_policy_should_return_false_for_no_name_and_version() + { + // arrange + var options = new ApiVersioningOptions(); + var manager = NewDeprecationPolicyManager( options ); + + // act + var result = manager.TryGetPolicy( default, default, out _ ); + + // assert + result.Should().BeFalse(); + } + + [Fact] + public void try_get_policy_should_return_false_without_any_policies() + { + // arrange + var options = new ApiVersioningOptions(); + var manager = NewDeprecationPolicyManager( options ); + + // act + var result = manager.TryGetPolicy( ApiVersion.Default, out _ ); + + // assert + result.Should().BeFalse(); + } + + [Fact] + public void try_get_policy_should_return_true_for_matching_policy() + { + // arrange + var options = new ApiVersioningOptions(); + var manager = NewDeprecationPolicyManager( options ); + + options.Policies.Deprecate( ApiVersion.Default ).Effective( 2022, 2, 1 ); + + // act + var result = manager.TryGetPolicy( ApiVersion.Default, out var policy ); + + // assert + result.Should().BeTrue(); + policy.Should().NotBeNull(); + } + + private static DeprecationPolicyManager NewDeprecationPolicyManager( ApiVersioningOptions options ) + { +#if NETFRAMEWORK + return new( options ); +#else + return new(Options.Create(options)); +#endif + } +} \ No newline at end of file