From 6a9ced1d7b7b197408b7297bf807b7f2c8714686 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Wed, 16 Nov 2022 09:46:04 -0800 Subject: [PATCH 1/6] Add MapApiGroup and validation --- .../WebApi/MinimalApiExample/Program.cs | 33 +- .../WebApi/MinimalOpenApiExample/Program.cs | 361 ++++++----- .../Builder/EndpointBuilderFinalizer.cs | 280 ++++++++ .../IEndpointConventionBuilderExtensions.cs | 611 +++++++++++------- .../IEndpointRouteBuilderExtensions.cs | 64 ++ .../Builder/RouteHandlerBuilderExtensions.cs | 417 ------------ .../Builder/VersionedEndpointRouteBuilder.cs | 7 +- .../src/Asp.Versioning.Http/SR.Designer.cs | 27 + .../WebApi/src/Asp.Versioning.Http/SR.resx | 9 + ...EndpointConventionBuilderExtensionsTest.cs | 430 ++++++++++++ .../IEndpointRouteBuilderExtensionsTest.cs | 81 +++ .../RouteHandlerBuilderExtensionsTest.cs | 298 --------- 12 files changed, 1487 insertions(+), 1131 deletions(-) create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs delete mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/RouteHandlerBuilderExtensions.cs delete mode 100644 src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/RouteHandlerBuilderExtensionsTest.cs diff --git a/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs b/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs index 10aa2033..d1ba28d2 100644 --- a/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs +++ b/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs @@ -17,10 +17,10 @@ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; -var forecast = app.MapGroup( "/weatherforecast" ).WithApiVersionSet(); +var forecast = app.MapApiGroup(); // GET /weatherforecast?api-version=1.0 -forecast.MapGet( "/", () => +forecast.MapGet( "/weatherforecast", () => { return Enumerable.Range( 1, 5 ).Select( index => new WeatherForecast @@ -33,24 +33,25 @@ .HasApiVersion( 1.0 ); // GET /weatherforecast?api-version=2.0 -forecast.MapGet( "/", () => - { - return Enumerable.Range( 0, summaries.Length ).Select( index => - new WeatherForecast - ( - DateTime.Now.AddDays( index ), - Random.Shared.Next( -20, 55 ), - summaries[Random.Shared.Next( summaries.Length )] - ) ); - } ) - .HasApiVersion( 2.0 ); +var v2 = forecast.MapGroup( "/weatherforecast" ) + .HasApiVersion( 2.0 ); + +v2.MapGet( "/", () => + { + return Enumerable.Range( 0, summaries.Length ).Select( index => + new WeatherForecast + ( + DateTime.Now.AddDays( index ), + Random.Shared.Next( -20, 55 ), + summaries[Random.Shared.Next( summaries.Length )] + ) ); + } ); // POST /weatherforecast?api-version=2.0 -forecast.MapPost( "/", ( WeatherForecast forecast ) => { } ) - .HasApiVersion( 2.0 ); +v2.MapPost( "/", ( WeatherForecast forecast ) => Results.Ok() ); // DELETE /weatherforecast -forecast.MapDelete( "/", () => Results.NoContent() ) +forecast.MapDelete( "/weatherforecast", () => Results.NoContent() ) .IsApiVersionNeutral(); app.Run(); diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs index 7895b722..b912b0da 100644 --- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs +++ b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs @@ -47,217 +47,220 @@ // Configure the HTTP request pipeline. var app = builder.Build(); -var orders = app.MapGroup( "/api/orders" ).WithApiVersionSet( "Orders" ); -var people = app.MapGroup( "/api/v{version:apiVersion}/people" ).WithApiVersionSet( "People" ); +var orders = app.MapApiGroup( "Orders" ); +var people = app.MapApiGroup( "People" ); // 1.0 -orders.MapGet( "/{id:int}", ( int id ) => new OrderV1() { Id = id, Customer = "John Doe" } ) - .Produces() - .Produces( 404 ) - .HasDeprecatedApiVersion( 0.9 ) - .HasApiVersion( 1.0 ); +var o1 = orders.MapGroup( "/api/orders" ) + .HasDeprecatedApiVersion( 0.9 ) + .HasApiVersion( 1.0 ); -orders.MapPost( "/", ( HttpRequest request, OrderV1 order ) => - { - order.Id = 42; - var scheme = request.Scheme; - var host = request.Host; - var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); - return Results.Created( location, order ); - } ) - .Accepts( "application/json" ) - .Produces( 201 ) - .Produces( 400 ) - .HasApiVersion( 1.0 ); +o1.MapGet( "/{id:int}", ( int id ) => new OrderV1() { Id = id, Customer = "John Doe" } ) + .Produces() + .Produces( 404 ); + +o1.MapPost( "/", ( HttpRequest request, OrderV1 order ) => + { + order.Id = 42; + var scheme = request.Scheme; + var host = request.Host; + var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); + return Results.Created( location, order ); + } ) + .Accepts( "application/json" ) + .Produces( 201 ) + .Produces( 400 ) + .MapToApiVersion( 1.0 ); -orders.MapMethods( "/{id:int}", new[] { HttpMethod.Patch.Method }, ( int id, OrderV1 order ) => Results.NoContent() ) - .Accepts( "application/json" ) - .Produces( 204 ) - .Produces( 400 ) - .Produces( 404 ) - .HasApiVersion( 1.0 ); +o1.MapMethods( "/{id:int}", new[] { HttpMethod.Patch.Method }, ( int id, OrderV1 order ) => Results.NoContent() ) + .Accepts( "application/json" ) + .Produces( 204 ) + .Produces( 400 ) + .Produces( 404 ) + .MapToApiVersion( 1.0 ); // 2.0 -orders.MapGet( "/", () => - new OrderV2[] - { - new(){ Id = 1, Customer = "John Doe" }, - new(){ Id = 2, Customer = "Bob Smith" }, - new(){ Id = 3, Customer = "Jane Doe", EffectiveDate = DateTimeOffset.UtcNow.AddDays( 7d ) }, - } ) - .Produces>() - .Produces( 404 ) - .HasApiVersion( 2.0 ); +var o2 = orders.MapGroup( "/api/orders" ) + .HasApiVersion( 2.0 ); -orders.MapGet( "/{id:int}", ( int id ) => new OrderV2() { Id = id, Customer = "John Doe" } ) - .Produces() - .Produces( 404 ) - .HasApiVersion( 2.0 ); +o2.MapGet( "/", () => + new OrderV2[] + { + new(){ Id = 1, Customer = "John Doe" }, + new(){ Id = 2, Customer = "Bob Smith" }, + new(){ Id = 3, Customer = "Jane Doe", EffectiveDate = DateTimeOffset.UtcNow.AddDays( 7d ) }, + } ) + .Produces>() + .Produces( 404 ); -orders.MapPost( "/", ( HttpRequest request, OrderV2 order ) => - { - order.Id = 42; - var scheme = request.Scheme; - var host = request.Host; - var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); - return Results.Created( location, order ); - } ) - .Accepts( "application/json" ) - .Produces( 201 ) - .Produces( 400 ) - .HasApiVersion( 2.0 ); +o2.MapGet( "/{id:int}", ( int id ) => new OrderV2() { Id = id, Customer = "John Doe" } ) + .Produces() + .Produces( 404 ); + +o2.MapPost( "/", ( HttpRequest request, OrderV2 order ) => + { + order.Id = 42; + var scheme = request.Scheme; + var host = request.Host; + var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); + return Results.Created( location, order ); + } ) + .Accepts( "application/json" ) + .Produces( 201 ) + .Produces( 400 ); -orders.MapMethods( "/{id:int}", new[] { HttpMethod.Patch.Method }, ( int id, OrderV2 order ) => Results.NoContent() ) - .Accepts( "application/json" ) - .Produces( 204 ) - .Produces( 400 ) - .Produces( 404 ) - .HasApiVersion( 2.0 ); +o2.MapMethods( "/{id:int}", new[] { HttpMethod.Patch.Method }, ( int id, OrderV2 order ) => Results.NoContent() ) + .Accepts( "application/json" ) + .Produces( 204 ) + .Produces( 400 ) + .Produces( 404 ); // 3.0 -orders.MapGet( "/", () => - new OrderV3[] - { - new(){ Id = 1, Customer = "John Doe" }, - new(){ Id = 2, Customer = "Bob Smith" }, - new(){ Id = 3, Customer = "Jane Doe", EffectiveDate = DateTimeOffset.UtcNow.AddDays( 7d ) }, - } ) - .Produces>() - .HasApiVersion( 3.0 ); +var o3 = orders.MapGroup( "/api/orders" ) + .HasApiVersion( 3.0 ); -orders.MapGet( "/{id:int}", ( int id ) => new OrderV3() { Id = id, Customer = "John Doe" } ) - .Produces() - .Produces( 404 ) - .HasApiVersion( 3.0 ); +o3.MapGet( "/", () => + new OrderV3[] + { + new(){ Id = 1, Customer = "John Doe" }, + new(){ Id = 2, Customer = "Bob Smith" }, + new(){ Id = 3, Customer = "Jane Doe", EffectiveDate = DateTimeOffset.UtcNow.AddDays( 7d ) }, + } ) + .Produces>(); -orders.MapPost( "/", ( HttpRequest request, OrderV3 order ) => - { - order.Id = 42; - var scheme = request.Scheme; - var host = request.Host; - var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); - return Results.Created( location, order ); - } ) - .Accepts( "application/json" ) - .Produces( 201 ) - .Produces( 400 ) - .HasApiVersion( 3.0 ); +o3.MapGet( "/{id:int}", ( int id ) => new OrderV3() { Id = id, Customer = "John Doe" } ) + .Produces() + .Produces( 404 ); + +o3.MapPost( "/", ( HttpRequest request, OrderV3 order ) => + { + order.Id = 42; + var scheme = request.Scheme; + var host = request.Host; + var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); + return Results.Created( location, order ); + } ) + .Accepts( "application/json" ) + .Produces( 201 ) + .Produces( 400 ); -orders.MapDelete( "/{id:int}", ( int id ) => Results.NoContent() ) - .Produces( 204 ) - .HasApiVersion( 3.0 ); +o3.MapDelete( "/{id:int}", ( int id ) => Results.NoContent() ) + .Produces( 204 ); // 1.0 -people.MapGet( "/{id:int}", ( int id ) => - new PersonV1() - { - Id = id, - FirstName = "John", - LastName = "Doe", - } ) - .Produces() - .Produces( 404 ) - .HasDeprecatedApiVersion( 0.9 ) - .HasApiVersion( 1.0 ); +var p1 = people.MapGroup( "/api/v{version:apiVersion}/people" ) + .HasDeprecatedApiVersion( 0.9 ) + .HasApiVersion( 1.0 ); + +p1.MapGet( "/{id:int}", ( int id ) => + new PersonV1() + { + Id = id, + FirstName = "John", + LastName = "Doe", + } ) + .Produces() + .Produces( 404 ); // 2.0 -people.MapGet( "/", () => - new PersonV2[] - { - new() - { - Id = 1, - FirstName = "John", - LastName = "Doe", - Email = "john.doe@somewhere.com", - }, - new() - { - Id = 2, - FirstName = "Bob", - LastName = "Smith", - Email = "bob.smith@somewhere.com", - }, - new() - { - Id = 3, - FirstName = "Jane", - LastName = "Doe", - Email = "jane.doe@somewhere.com", - }, - } ) - .Produces>() - .HasApiVersion( 2.0 ); +var p2 = people.MapGroup( "/api/v{version:apiVersion}/people" ) + .HasApiVersion( 2.0 ); -people.MapGet( "/{id:int}", ( int id ) => - new PersonV2() +p2.MapGet( "/", () => + new PersonV2[] + { + new() { - Id = id, + Id = 1, FirstName = "John", LastName = "Doe", Email = "john.doe@somewhere.com", - } ) - .Produces() - .Produces( 404 ) - .HasApiVersion( 2.0 ); + }, + new() + { + Id = 2, + FirstName = "Bob", + LastName = "Smith", + Email = "bob.smith@somewhere.com", + }, + new() + { + Id = 3, + FirstName = "Jane", + LastName = "Doe", + Email = "jane.doe@somewhere.com", + }, + } ) + .Produces>(); + +p2.MapGet( "/{id:int}", ( int id ) => + new PersonV2() + { + Id = id, + FirstName = "John", + LastName = "Doe", + Email = "john.doe@somewhere.com", + } ) + .Produces() + .Produces( 404 ); // 3.0 -people.MapGet( "/", () => - new PersonV3[] - { - new() - { - Id = 1, - FirstName = "John", - LastName = "Doe", - Email = "john.doe@somewhere.com", - Phone = "555-987-1234", - }, - new() - { - Id = 2, - FirstName = "Bob", - LastName = "Smith", - Email = "bob.smith@somewhere.com", - Phone = "555-654-4321", - }, - new() - { - Id = 3, - FirstName = "Jane", - LastName = "Doe", - Email = "jane.doe@somewhere.com", - Phone = "555-789-3456", - }, - } ) - .Produces>() - .HasApiVersion( 3.0 ); +var p3 = people.MapGroup( "/api/v{version:apiVersion}/people" ) + .HasApiVersion( 3.0 ); -people.MapGet( "/{id:int}", ( int id ) => - new PersonV3() +p3.MapGet( "/", () => + new PersonV3[] + { + new() { - Id = id, + Id = 1, FirstName = "John", LastName = "Doe", Email = "john.doe@somewhere.com", Phone = "555-987-1234", - } ) - .Produces() - .Produces( 404 ) - .HasApiVersion( 3.0 ); - -people.MapPost( "/", ( HttpRequest request, ApiVersion version, PersonV3 person ) => + }, + new() + { + Id = 2, + FirstName = "Bob", + LastName = "Smith", + Email = "bob.smith@somewhere.com", + Phone = "555-654-4321", + }, + new() { - person.Id = 42; - var scheme = request.Scheme; - var host = request.Host; - var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/v{version}/api/people/{person.Id}" ); - return Results.Created( location, person ); - } ) - .Accepts( "application/json" ) - .Produces( 201 ) - .Produces( 400 ) - .HasApiVersion( 3.0 ); + Id = 3, + FirstName = "Jane", + LastName = "Doe", + Email = "jane.doe@somewhere.com", + Phone = "555-789-3456", + }, + } ) + .Produces>(); + +p3.MapGet( "/{id:int}", ( int id ) => + new PersonV3() + { + Id = id, + FirstName = "John", + LastName = "Doe", + Email = "john.doe@somewhere.com", + Phone = "555-987-1234", + } ) + .Produces() + .Produces( 404 ); + +p3.MapPost( "/", ( HttpRequest request, ApiVersion version, PersonV3 person ) => + { + person.Id = 42; + var scheme = request.Scheme; + var host = request.Host; + var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/v{version}/api/people/{person.Id}" ); + return Results.Created( location, person ); + } ) + .Accepts( "application/json" ) + .Produces( 201 ) + .Produces( 400 ); app.UseSwagger(); app.UseSwaggerUI( diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs new file mode 100644 index 00000000..c944377f --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs @@ -0,0 +1,280 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Builder; + +using Asp.Versioning; +using Asp.Versioning.Routing; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using System.Globalization; +using System.Runtime.CompilerServices; +using static Asp.Versioning.ApiVersionParameterLocation; +using static Asp.Versioning.ApiVersionProviderOptions; + +internal static class EndpointBuilderFinalizer +{ + internal static void FinalizeEndpoints( EndpointBuilder endpointBuilder ) + { + var versionSet = GetApiVersionSet( endpointBuilder.Metadata ); + Finialize( endpointBuilder, versionSet ); + } + + internal static void FinalizeRoutes( EndpointBuilder endpointBuilder ) + { + var versionSet = endpointBuilder.ApplicationServices.GetService(); + Finialize( endpointBuilder, versionSet ); + } + + private static void Finialize( EndpointBuilder endpointBuilder, ApiVersionSet? versionSet ) + { + if ( versionSet is null ) + { + // this should be impossible because WithApiVersionSet had to be called to get here + endpointBuilder.Metadata.Add( ApiVersionMetadata.Empty ); + return; + } + + var services = endpointBuilder.ApplicationServices; + var endpointMetadata = endpointBuilder.Metadata; + var options = services.GetRequiredService>().Value; + var metadata = Build( endpointMetadata, versionSet, options ); + var reportApiVersions = ReportApiVersions( endpointMetadata ) || + options.ReportApiVersions || + versionSet.ReportApiVersions; + + endpointBuilder.Metadata.Add( metadata ); + + var requestDelegate = default( RequestDelegate ); + + if ( reportApiVersions ) + { + requestDelegate = EnsureRequestDelegate( requestDelegate, endpointBuilder.RequestDelegate ); + requestDelegate = new ReportApiVersionsDecorator( requestDelegate, metadata ); + endpointBuilder.RequestDelegate = requestDelegate; + } + + var parameterSource = endpointBuilder.ApplicationServices.GetRequiredService(); + + if ( parameterSource.VersionsByMediaType() ) + { + var parameterName = parameterSource.GetParameterName( MediaTypeParameter ); + + if ( !string.IsNullOrEmpty( parameterName ) ) + { + requestDelegate = EnsureRequestDelegate( requestDelegate, endpointBuilder.RequestDelegate ); + requestDelegate = new ContentTypeApiVersionDecorator( requestDelegate, parameterName ); + endpointBuilder.RequestDelegate = requestDelegate; + } + } + } + + private static bool IsApiVersionNeutral( IList metadata ) + { + var versionNeutral = false; + + for ( var i = metadata.Count - 1; i >= 0; i-- ) + { + if ( metadata[i] is IApiVersionNeutral ) + { + versionNeutral = true; + metadata.RemoveAt( i ); + break; + } + } + + if ( versionNeutral ) + { + for ( var i = metadata.Count - 1; i >= 0; i-- ) + { + switch ( metadata[i] ) + { + case IApiVersionProvider: + case IApiVersionNeutral: + metadata.RemoveAt( i ); + break; + } + } + } + + return versionNeutral; + } + + private static bool ReportApiVersions( IList metadata ) + { + var result = false; + + for ( var i = metadata.Count - 1; i >= 0; i-- ) + { + if ( metadata[i] is IReportApiVersions ) + { + result = true; + metadata.RemoveAt( i ); + } + } + + return result; + } + + private static ApiVersionSet? GetApiVersionSet( IList metadata ) + { + var result = default( ApiVersionSet ); + + for ( var i = metadata.Count - 1; i >= 0; i-- ) + { + if ( metadata[i] is ApiVersionSet set ) + { + result ??= set; + metadata.RemoveAt( i ); + } + } + + return result; + } + + private static bool TryGetApiVersions( IList metadata, out ApiVersionBuckets buckets ) + { + if ( IsApiVersionNeutral( metadata ) ) + { + buckets = default; + return false; + } + + var mapped = default( SortedSet ); + var supported = default( SortedSet ); + var deprecated = default( SortedSet ); + var advertised = default( SortedSet ); + var deprecatedAdvertised = default( SortedSet ); + + for ( var i = metadata.Count - 1; i >= 0; i-- ) + { + var item = metadata[i]; + + if ( item is not IApiVersionProvider provider ) + { + continue; + } + + metadata.RemoveAt( i ); + + var versions = provider.Versions; + var target = provider.Options switch + { + None => supported ??= new(), + Mapped => mapped ??= new(), + Deprecated => deprecated ??= new(), + Advertised => advertised ??= new(), + Advertised | Deprecated => deprecatedAdvertised ??= new(), + _ => default, + }; + + if ( target is null ) + { + continue; + } + + for ( var j = 0; j < versions.Count; j++ ) + { + target.Add( versions[j] ); + } + } + + buckets = new( + mapped?.ToArray() ?? Array.Empty(), + supported?.ToArray() ?? Array.Empty(), + deprecated?.ToArray() ?? Array.Empty(), + advertised?.ToArray() ?? Array.Empty(), + deprecatedAdvertised?.ToArray() ?? Array.Empty() ); + + return true; + } + + private static ApiVersionMetadata Build( IList metadata, ApiVersionSet versionSet, ApiVersioningOptions options ) + { + var name = versionSet.Name; + ApiVersionModel? apiModel; + + if ( !TryGetApiVersions( metadata, out var buckets ) || + ( apiModel = versionSet.Build( options ) ).IsApiVersionNeutral ) + { + if ( string.IsNullOrEmpty( name ) ) + { + return ApiVersionMetadata.Neutral; + } + + return new( ApiVersionModel.Neutral, ApiVersionModel.Neutral, name ); + } + + ApiVersionModel endpointModel; + ApiVersion[] emptyVersions; + var inheritedSupported = apiModel.SupportedApiVersions; + var inheritedDeprecated = apiModel.DeprecatedApiVersions; + var (mapped, supported, deprecated, advertised, advertisedDeprecated) = buckets; + var isEmpty = mapped.Count == 0 && + supported.Count == 0 && + deprecated.Count == 0 && + advertised.Count == 0 && + advertisedDeprecated.Count == 0; + + if ( isEmpty ) + { + var noInheritedApiVersions = inheritedSupported.Count == 0 && + inheritedDeprecated.Count == 0; + + if ( noInheritedApiVersions ) + { + endpointModel = ApiVersionModel.Empty; + } + else + { + emptyVersions = Array.Empty(); + endpointModel = new( + declaredVersions: emptyVersions, + inheritedSupported, + inheritedDeprecated, + emptyVersions, + emptyVersions ); + } + } + else if ( mapped.Count == 0 ) + { + endpointModel = new( + declaredVersions: supported.Union( deprecated ), + supported.Union( inheritedSupported ), + deprecated.Union( inheritedDeprecated ), + advertised, + advertisedDeprecated ); + } + else + { + emptyVersions = Array.Empty(); + endpointModel = new( + declaredVersions: mapped, + supportedVersions: inheritedSupported, + deprecatedVersions: inheritedDeprecated, + advertisedVersions: emptyVersions, + deprecatedAdvertisedVersions: emptyVersions ); + } + + return new( apiModel, endpointModel, name ); + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + private static RequestDelegate EnsureRequestDelegate( RequestDelegate? current, RequestDelegate? original ) => + ( current ?? original ) ?? + throw new InvalidOperationException( + string.Format( + CultureInfo.CurrentCulture, + SR.UnsetRequestDelegate, + nameof( RequestDelegate ), + nameof( RouteEndpoint ) ) ); + + private record struct ApiVersionBuckets( + IReadOnlyList Mapped, + IReadOnlyList Supported, + IReadOnlyList Deprecated, + IReadOnlyList Advertised, + IReadOnlyList AdvertisedDeprecated ); +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs index a37fac92..f774fac2 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs @@ -4,14 +4,9 @@ namespace Microsoft.AspNetCore.Builder; using Asp.Versioning; using Asp.Versioning.Builder; -using Asp.Versioning.Routing; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; +using System.Collections; using System.Globalization; -using System.Runtime.CompilerServices; -using static Asp.Versioning.ApiVersionParameterLocation; using static Asp.Versioning.ApiVersionProviderOptions; /// @@ -38,292 +33,468 @@ public static TBuilder WithApiVersionSet( } builder.Add( endpoint => endpoint.Metadata.Add( apiVersionSet ) ); - builder.Finally( FinalizeEndpoints ); + builder.Finally( EndpointBuilderFinalizer.FinalizeEndpoints ); return builder; } /// - /// Applies the specified API version set to the endpoint group. + /// Indicates that the specified API version is mapped to the configured endpoint. /// - /// The type of builder. - /// The extended builder. - /// The optional name associated with the builder. - /// A new instance. - public static IVersionedEndpointRouteBuilder WithApiVersionSet( this TBuilder builder, string? name = default ) - where TBuilder : notnull, IEndpointRouteBuilder, IEndpointConventionBuilder - { - if ( builder is IVersionedEndpointRouteBuilder versionedBuilder ) - { - return versionedBuilder; - } + /// The extended type. + /// The extended endpoint convention builder. + /// The major version number. + /// The optional minor version number. + /// The optional version status. + /// The original . + public static TBuilder MapToApiVersion( this TBuilder builder, int majorVersion, int? minorVersion = default, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.MapToApiVersion( new ApiVersion( majorVersion, minorVersion, status ) ); - var factory = builder.ServiceProvider.GetRequiredService(); + /// + /// Indicates that the specified API version is mapped to the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The version number. + /// The optional version status. + /// The original . + public static TBuilder MapToApiVersion( this TBuilder builder, double version, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.MapToApiVersion( new ApiVersion( version, status ) ); - versionedBuilder = new VersionedEndpointRouteBuilder( builder, builder, factory.Create( name ) ); - builder.Finally( FinalizeRoutes ); + /// + /// Indicates that the specified API version is mapped to the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The version year. + /// The version month. + /// The version day. + /// The optional version status. + /// The original . + public static TBuilder MapToApiVersion( this TBuilder builder, int year, int month, int day, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.MapToApiVersion( new ApiVersion( new DateOnly( year, month, day ), status ) ); - return versionedBuilder; - } + /// + /// Indicates that the specified API version is mapped to the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The group version. + /// The optional version status. + /// The original . + public static TBuilder MapToApiVersion( this TBuilder builder, DateOnly groupVersion, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.MapToApiVersion( new ApiVersion( groupVersion, status ) ); - private static void FinalizeEndpoints( EndpointBuilder endpointBuilder ) + /// + /// Maps the specified API version to the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The API version to map to the endpoint. + /// The original . + public static TBuilder MapToApiVersion( this TBuilder builder, ApiVersion apiVersion ) + where TBuilder : notnull, IEndpointConventionBuilder { - var versionSet = GetApiVersionSet( endpointBuilder.Metadata ); - Finialize( endpointBuilder, versionSet ); + builder.Add( endpoint => AddMetadata( endpoint, Convention.MapToApiVersion( apiVersion ) ) ); + return builder; } - private static void FinalizeRoutes( EndpointBuilder endpointBuilder ) + /// + /// Indicates that the endpoint is API version-neutral. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The original . + public static TBuilder IsApiVersionNeutral( this TBuilder builder ) + where TBuilder : notnull, IEndpointConventionBuilder { - var versionSet = endpointBuilder.ApplicationServices.GetService(); - Finialize( endpointBuilder, versionSet ); + builder.Add( endpoint => AddMetadata( endpoint, new ApiVersionNeutralAttribute() ) ); + return builder; } - private static void Finialize( EndpointBuilder endpointBuilder, ApiVersionSet? versionSet ) - { - if ( versionSet is null ) - { - // this should be impossible because WithApiVersionSet had to be called to get here - endpointBuilder.Metadata.Add( ApiVersionMetadata.Empty ); - return; - } + /// + /// Indicates that the specified API version is supported by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The major version number. + /// The optional minor version number. + /// The optional version status. + /// The original . + public static TBuilder HasApiVersion( this TBuilder builder, int majorVersion, int? minorVersion = default, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.HasApiVersion( new ApiVersion( majorVersion, minorVersion, status ) ); - var services = endpointBuilder.ApplicationServices; - var endpointMetadata = endpointBuilder.Metadata; - var options = services.GetRequiredService>().Value; - var metadata = Build( endpointMetadata, versionSet, options ); - var reportApiVersions = ReportApiVersions( endpointMetadata ) || - options.ReportApiVersions || - versionSet.ReportApiVersions; + /// + /// Indicates that the specified API version is supported by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The version number. + /// The optional version status. + /// The original . + public static TBuilder HasApiVersion( this TBuilder builder, double version, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.HasApiVersion( new ApiVersion( version, status ) ); - endpointBuilder.Metadata.Add( metadata ); + /// + /// Indicates that the specified API version is supported by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The version year. + /// The version month. + /// The version day. + /// The optional version status. + /// The original . + public static TBuilder HasApiVersion( this TBuilder builder, int year, int month, int day, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.HasApiVersion( new ApiVersion( new DateOnly( year, month, day ), status ) ); - var requestDelegate = default( RequestDelegate ); + /// + /// Indicates that the specified API version is supported by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The group version. + /// The optional version status. + /// The original . + public static TBuilder HasApiVersion( this TBuilder builder, DateOnly groupVersion, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.HasApiVersion( new ApiVersion( groupVersion, status ) ); - if ( reportApiVersions ) - { - requestDelegate = EnsureRequestDelegate( requestDelegate, endpointBuilder.RequestDelegate ); - requestDelegate = new ReportApiVersionsDecorator( requestDelegate, metadata ); - endpointBuilder.RequestDelegate = requestDelegate; - } + /// + /// Indicates that the specified API version is supported by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The supported API version implemented by the endpoint. + /// The original . + public static TBuilder HasApiVersion( this TBuilder builder, ApiVersion apiVersion ) + where TBuilder : notnull, IEndpointConventionBuilder + { + builder.Add( + endpoint => + { + AddMetadata( endpoint, Convention.HasApiVersion( apiVersion ) ); + AdvertiseInApiVersionSet( endpoint.Metadata, apiVersion ); + } ); - var parameterSource = endpointBuilder.ApplicationServices.GetRequiredService(); + return builder; + } - if ( parameterSource.VersionsByMediaType() ) - { - var parameterName = parameterSource.GetParameterName( MediaTypeParameter ); + /// + /// Indicates that the specified API version is deprecated by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The major version number. + /// The optional minor version number. + /// The optional version status. + /// The original . + public static TBuilder HasDeprecatedApiVersion( this TBuilder builder, int majorVersion, int? minorVersion = default, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.HasDeprecatedApiVersion( new ApiVersion( majorVersion, minorVersion, status ) ); + + /// + /// Indicates that the specified API version is deprecated by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The version number. + /// The optional version status. + /// The original . + public static TBuilder HasDeprecatedApiVersion( this TBuilder builder, double version, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.HasDeprecatedApiVersion( new ApiVersion( version, status ) ); + + /// + /// Indicates that the specified API version is deprecated by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The version year. + /// The version month. + /// The version day. + /// The optional version status. + /// The original . + public static TBuilder HasDeprecatedApiVersion( this TBuilder builder, int year, int month, int day, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.HasDeprecatedApiVersion( new ApiVersion( new DateOnly( year, month, day ), status ) ); + + /// + /// Indicates that the specified API version is deprecated by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The group version. + /// The optional version status. + /// The original . + public static TBuilder HasDeprecatedApiVersion( this TBuilder builder, DateOnly groupVersion, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.HasDeprecatedApiVersion( new ApiVersion( groupVersion, status ) ); - if ( !string.IsNullOrEmpty( parameterName ) ) + /// + /// Indicates that the specified API version is deprecated by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The deprecated API version implemented by the endpoint. + /// The original . + public static TBuilder HasDeprecatedApiVersion( this TBuilder builder, ApiVersion apiVersion ) + where TBuilder : notnull, IEndpointConventionBuilder + { + builder.Add( + endpoint => { - requestDelegate = EnsureRequestDelegate( requestDelegate, endpointBuilder.RequestDelegate ); - requestDelegate = new ContentTypeApiVersionDecorator( requestDelegate, parameterName ); - endpointBuilder.RequestDelegate = requestDelegate; - } - } + AddMetadata( endpoint, Convention.HasDeprecatedApiVersion( apiVersion ) ); + AdvertiseDeprecatedInApiVersionSet( endpoint.Metadata, apiVersion ); + } ); + + return builder; } - private static bool IsApiVersionNeutral( IList metadata ) - { - var versionNeutral = false; + /// + /// Indicates that the specified API version is advertised by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The major version number. + /// The optional minor version number. + /// The optional version status. + /// The original . + public static TBuilder AdvertisesApiVersion( this TBuilder builder, int majorVersion, int? minorVersion = default, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.AdvertisesApiVersion( new ApiVersion( majorVersion, minorVersion, status ) ); - for ( var i = metadata.Count - 1; i >= 0; i-- ) - { - if ( metadata[i] is IApiVersionNeutral ) + /// + /// Indicates that the specified API version is advertised by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The version number. + /// The optional version status. + /// The original . + public static TBuilder AdvertisesApiVersion( this TBuilder builder, double version, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.AdvertisesApiVersion( new ApiVersion( version, status ) ); + + /// + /// Indicates that the specified API version is advertised by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The version year. + /// The version month. + /// The version day. + /// The optional version status. + /// The original . + public static TBuilder AdvertisesApiVersion( this TBuilder builder, int year, int month, int day, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.AdvertisesApiVersion( new ApiVersion( new DateOnly( year, month, day ), status ) ); + + /// + /// Indicates that the specified API version is advertised by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The group version. + /// The optional version status. + /// The original . + public static TBuilder AdvertisesApiVersion( this TBuilder builder, DateOnly groupVersion, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.AdvertisesApiVersion( new ApiVersion( groupVersion, status ) ); + + /// + /// Indicates that the specified API version is advertised by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The advertised API version not directly implemented by the endpoint. + /// The original . + public static TBuilder AdvertisesApiVersion( this TBuilder builder, ApiVersion apiVersion ) + where TBuilder : notnull, IEndpointConventionBuilder + { + builder.Add( + endpoint => { - versionNeutral = true; - metadata.RemoveAt( i ); - break; - } - } + AddMetadata( endpoint, Convention.AdvertisesApiVersion( apiVersion ) ); + AdvertiseInApiVersionSet( endpoint.Metadata, apiVersion ); + } ); - if ( versionNeutral ) - { - for ( var i = metadata.Count - 1; i >= 0; i-- ) + return builder; + } + + /// + /// Indicates that the specified API version is advertised and deprecated by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The major version number. + /// The optional minor version number. + /// The optional version status. + /// The original . + public static TBuilder AdvertisesDeprecatedApiVersion( this TBuilder builder, int majorVersion, int? minorVersion = default, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.AdvertisesDeprecatedApiVersion( new ApiVersion( majorVersion, minorVersion, status ) ); + + /// + /// Indicates that the specified API version is advertised and deprecated by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The version number. + /// The optional version status. + /// The original . + public static TBuilder AdvertisesDeprecatedApiVersion( this TBuilder builder, double version, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.AdvertisesDeprecatedApiVersion( new ApiVersion( version, status ) ); + + /// + /// Indicates that the specified API version is advertised and deprecated by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The version year. + /// The version month. + /// The version day. + /// The version status. + /// The original . + public static TBuilder AdvertisesDeprecatedApiVersion( this TBuilder builder, int year, int month, int day, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.AdvertisesDeprecatedApiVersion( new ApiVersion( new DateOnly( year, month, day ), status ) ); + + /// + /// Indicates that the specified API version is advertised and deprecated by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The group version. + /// The optional version status. + /// The original . + public static TBuilder AdvertisesDeprecatedApiVersion( this TBuilder builder, DateOnly groupVersion, string? status = default ) + where TBuilder : notnull, IEndpointConventionBuilder => builder.AdvertisesDeprecatedApiVersion( new ApiVersion( groupVersion, status ) ); + + /// + /// Indicates that the specified API version is advertised and deprecated by the configured endpoint. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The advertised, but deprecated API version not directly implemented by the endpoint. + /// The original . + public static TBuilder AdvertisesDeprecatedApiVersion( this TBuilder builder, ApiVersion apiVersion ) + where TBuilder : notnull, IEndpointConventionBuilder + { + builder.Add( + endpoint => { - switch ( metadata[i] ) - { - case IApiVersionProvider: - case IApiVersionNeutral: - metadata.RemoveAt( i ); - break; - } - } - } + AddMetadata( endpoint, Convention.AdvertisesDeprecatedApiVersion( apiVersion ) ); + AdvertiseDeprecatedInApiVersionSet( endpoint.Metadata, apiVersion ); + } ); - return versionNeutral; + return builder; } - private static bool ReportApiVersions( IList metadata ) + /// + /// Indicates that the endpoint will report its API versions. + /// + /// The extended type. + /// The extended endpoint convention builder. + /// The original . + public static TBuilder ReportApiVersions( this TBuilder builder ) + where TBuilder : notnull, IEndpointConventionBuilder { - var result = false; + builder.Add( endpoint => AddMetadata( endpoint, Convention.ReportApiVersions ) ); + return builder; + } + private static void AdvertiseInApiVersionSet( IList metadata, ApiVersion apiVersion ) + { for ( var i = metadata.Count - 1; i >= 0; i-- ) { - if ( metadata[i] is IReportApiVersions ) + if ( metadata[i] is ApiVersionSet versionSet ) { - result = true; - metadata.RemoveAt( i ); + versionSet.AdvertisesApiVersion( apiVersion ); + break; } } - - return result; } - private static ApiVersionSet? GetApiVersionSet( IList metadata ) + private static void AdvertiseDeprecatedInApiVersionSet( IList metadata, ApiVersion apiVersion ) { - var result = default( ApiVersionSet ); - for ( var i = metadata.Count - 1; i >= 0; i-- ) { - if ( metadata[i] is ApiVersionSet set ) + if ( metadata[i] is ApiVersionSet versionSet ) { - result ??= set; - metadata.RemoveAt( i ); + versionSet.AdvertisesDeprecatedApiVersion( apiVersion ); + break; } } - - return result; } - private static bool TryGetApiVersions( IList metadata, out ApiVersionBuckets buckets ) + private static void AddMetadata( EndpointBuilder builder, object item ) { - if ( IsApiVersionNeutral( metadata ) ) + var metadata = builder.Metadata; + var grouped = builder.ApplicationServices.GetService( typeof( ApiVersionSetBuilder ) ) is not null; + + metadata.Add( item ); + + if ( grouped ) { - buckets = default; - return false; + return; } - var mapped = default( SortedSet ); - var supported = default( SortedSet ); - var deprecated = default( SortedSet ); - var advertised = default( SortedSet ); - var deprecatedAdvertised = default( SortedSet ); - for ( var i = metadata.Count - 1; i >= 0; i-- ) { - var item = metadata[i]; - - if ( item is not IApiVersionProvider provider ) + if ( metadata[i] is ApiVersionSet ) { - continue; + return; } + } + + throw new InvalidOperationException( + string.Format( + CultureInfo.CurrentCulture, + SR.NoVersionSet, + builder.DisplayName, + nameof( IEndpointRouteBuilderExtensions.MapApiGroup ), + nameof( IEndpointRouteBuilderExtensions.WithApiVersionSet ) ) ); + } - metadata.RemoveAt( i ); + private sealed class SingleItemReadOnlyList : IReadOnlyList + { + private readonly ApiVersion item; - var versions = provider.Versions; - var target = provider.Options switch - { - None => supported ??= new(), - Mapped => mapped ??= new(), - Deprecated => deprecated ??= new(), - Advertised => advertised ??= new(), - Advertised | Deprecated => deprecatedAdvertised ??= new(), - _ => default, - }; - - if ( target is null ) - { - continue; - } + internal SingleItemReadOnlyList( ApiVersion item ) => this.item = item; - for ( var j = 0; j < versions.Count; j++ ) - { - target.Add( versions[j] ); - } + public ApiVersion this[int index] => index == 0 ? item : throw new IndexOutOfRangeException(); + + public int Count => 1; + + public IEnumerator GetEnumerator() + { + yield return item; } - buckets = new( - mapped?.ToArray() ?? Array.Empty(), - supported?.ToArray() ?? Array.Empty(), - deprecated?.ToArray() ?? Array.Empty(), - advertised?.ToArray() ?? Array.Empty(), - deprecatedAdvertised?.ToArray() ?? Array.Empty() ); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } - return true; + private sealed class ReportApiVersionsConvention : IReportApiVersions + { + public ApiVersionMapping Mapping => ApiVersionMapping.None; + + public void Report( HttpResponse response, ApiVersionModel apiVersionModel ) { } } - private static ApiVersionMetadata Build( IList metadata, ApiVersionSet versionSet, ApiVersioningOptions options ) + private sealed class Convention : IApiVersionProvider { - var name = versionSet.Name; - ApiVersionModel? apiModel; + private static ReportApiVersionsConvention? reportApiVersions; - if ( !TryGetApiVersions( metadata, out var buckets ) || - ( apiModel = versionSet.Build( options ) ).IsApiVersionNeutral ) + private Convention( ApiVersion version, ApiVersionProviderOptions options ) { - if ( string.IsNullOrEmpty( name ) ) - { - return ApiVersionMetadata.Neutral; - } - - return new( ApiVersionModel.Neutral, ApiVersionModel.Neutral, name ); + Versions = new SingleItemReadOnlyList( version ); + Options = options; } - ApiVersionModel endpointModel; - ApiVersion[] emptyVersions; - var inheritedSupported = apiModel.SupportedApiVersions; - var inheritedDeprecated = apiModel.DeprecatedApiVersions; - var (mapped, supported, deprecated, advertised, advertisedDeprecated) = buckets; - var isEmpty = mapped.Count == 0 && - supported.Count == 0 && - deprecated.Count == 0 && - advertised.Count == 0 && - advertisedDeprecated.Count == 0; - - if ( isEmpty ) - { - var noInheritedApiVersions = inheritedSupported.Count == 0 && - inheritedDeprecated.Count == 0; + public ApiVersionProviderOptions Options { get; } - if ( noInheritedApiVersions ) - { - endpointModel = ApiVersionModel.Empty; - } - else - { - emptyVersions = Array.Empty(); - endpointModel = new( - declaredVersions: emptyVersions, - inheritedSupported, - inheritedDeprecated, - emptyVersions, - emptyVersions ); - } - } - else if ( mapped.Count == 0 ) - { - endpointModel = new( - declaredVersions: supported.Union( deprecated ), - supported.Union( inheritedSupported ), - deprecated.Union( inheritedDeprecated ), - advertised, - advertisedDeprecated ); - } - else - { - emptyVersions = Array.Empty(); - endpointModel = new( - declaredVersions: mapped, - supportedVersions: inheritedSupported, - deprecatedVersions: inheritedDeprecated, - advertisedVersions: emptyVersions, - deprecatedAdvertisedVersions: emptyVersions ); - } + public IReadOnlyList Versions { get; } - return new( apiModel, endpointModel, name ); - } + internal static IReportApiVersions ReportApiVersions => reportApiVersions ??= new(); - private static RequestDelegate EnsureRequestDelegate( RequestDelegate? current, RequestDelegate? original ) => - ( current ?? original ) ?? - throw new InvalidOperationException( - string.Format( - CultureInfo.CurrentCulture, - SR.UnsetRequestDelegate, - nameof( RequestDelegate ), - nameof( RouteEndpoint ) ) ); - - private record struct ApiVersionBuckets( - IReadOnlyList Mapped, - IReadOnlyList Supported, - IReadOnlyList Deprecated, - IReadOnlyList Advertised, - IReadOnlyList AdvertisedDeprecated ); + internal static Convention HasApiVersion( ApiVersion version ) => new( version, None ); + + internal static Convention HasDeprecatedApiVersion( ApiVersion version ) => new( version, Deprecated ); + + internal static Convention MapToApiVersion( ApiVersion version ) => new( version, Mapped ); + + internal static Convention AdvertisesApiVersion( ApiVersion version ) => new( version, Advertised ); + + internal static Convention AdvertisesDeprecatedApiVersion( ApiVersion version ) => new( version, Advertised | Deprecated ); + } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointRouteBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointRouteBuilderExtensions.cs index 65664447..54270a5f 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointRouteBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointRouteBuilderExtensions.cs @@ -2,9 +2,11 @@ namespace Microsoft.AspNetCore.Builder; +using Asp.Versioning; using Asp.Versioning.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using System.Runtime.CompilerServices; /// /// Provides extension methods for . @@ -29,4 +31,66 @@ public static class IEndpointRouteBuilderExtensions return factory.Create( name ); } + + /// + /// Applies the specified API version set to the endpoint group. + /// + /// The type of builder. + /// The extended builder. + /// The optional name associated with the builder. + /// A new instance. + public static IVersionedEndpointRouteBuilder WithApiVersionSet( this TBuilder builder, string? name = default ) + where TBuilder : notnull, IEndpointRouteBuilder, IEndpointConventionBuilder + { + if ( builder is null ) + { + throw new ArgumentNullException( nameof( builder ) ); + } + + if ( builder.HasMetadata() ) + { + throw new InvalidOperationException( SR.CannotNestVersionSet ); + } + + var factory = builder.ServiceProvider.GetRequiredService(); + + builder.Finally( EndpointBuilderFinalizer.FinalizeRoutes ); + + return new VersionedEndpointRouteBuilder( builder, builder, factory.Create( name ) ); + } + + /// + /// Creates a route group builder for defining all versioned endpoints in an API. + /// + /// The extended . + /// The optional name associated with the builder. + /// A new instance. + public static IVersionedEndpointRouteBuilder MapApiGroup( this IEndpointRouteBuilder builder, string? name = default ) + { + if ( builder is null ) + { + throw new ArgumentNullException( nameof( builder ) ); + } + + if ( builder.IsNestedGroup() ) + { + throw new InvalidOperationException( SR.CannotNestApiGroup ); + } + + var group = builder.MapGroup( string.Empty ); + IEndpointConventionBuilder convention = group; + var factory = builder.ServiceProvider.GetRequiredService(); + + convention.Finally( EndpointBuilderFinalizer.FinalizeRoutes ); + + return new VersionedEndpointRouteBuilder( group, group, factory.Create( name ) ); + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + private static bool HasMetadata( this IEndpointRouteBuilder builder ) => + builder.ServiceProvider.GetService() is not null; + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + private static bool IsNestedGroup( this IEndpointRouteBuilder builder ) => + builder is RouteGroupBuilder || builder.HasMetadata(); } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/RouteHandlerBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/RouteHandlerBuilderExtensions.cs deleted file mode 100644 index c8038c68..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/RouteHandlerBuilderExtensions.cs +++ /dev/null @@ -1,417 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Microsoft.AspNetCore.Builder; - -using Asp.Versioning; -using Asp.Versioning.Builder; -using Microsoft.AspNetCore.Http; -using System.Collections; -using static Asp.Versioning.ApiVersionProviderOptions; - -/// -/// Provides extension methods for . -/// -[CLSCompliant( false )] -public static class RouteHandlerBuilderExtensions -{ - /// - /// Indicates that the specified API version is mapped to the configured endpoint. - /// - /// The extended route handler builder. - /// The major version number. - /// The optional minor version number. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder MapToApiVersion( this RouteHandlerBuilder builder, int majorVersion, int? minorVersion = default, string? status = default ) => - builder.MapToApiVersion( new ApiVersion( majorVersion, minorVersion, status ) ); - - /// - /// Indicates that the specified API version is mapped to the configured endpoint. - /// - /// The extended route handler builder. - /// The version number. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder MapToApiVersion( this RouteHandlerBuilder builder, double version, string? status = default ) => - builder.MapToApiVersion( new ApiVersion( version, status ) ); - - /// - /// Indicates that the specified API version is mapped to the configured endpoint. - /// - /// The extended route handler builder. - /// The version year. - /// The version month. - /// The version day. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder MapToApiVersion( this RouteHandlerBuilder builder, int year, int month, int day, string? status = default ) => - builder.MapToApiVersion( new ApiVersion( new DateOnly( year, month, day ), status ) ); - - /// - /// Indicates that the specified API version is mapped to the configured endpoint. - /// - /// The extended route handler builder. - /// The group version. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder MapToApiVersion( this RouteHandlerBuilder builder, DateOnly groupVersion, string? status = default ) => - builder.MapToApiVersion( new ApiVersion( groupVersion, status ) ); - - /// - /// Maps the specified API version to the configured endpoint. - /// - /// The extended route handler builder. - /// The API version to map to the endpoint. - /// The original . - public static RouteHandlerBuilder MapToApiVersion( this RouteHandlerBuilder builder, ApiVersion apiVersion ) - { - builder.Add( endpoint => endpoint.Metadata.Add( Convention.MapToApiVersion( apiVersion ) ) ); - return builder; - } - - /// - /// Indicates that the endpoint is API version-neutral. - /// - /// The extended route handler builder. - /// The original . - public static RouteHandlerBuilder IsApiVersionNeutral( this RouteHandlerBuilder builder ) - { - builder.Add( endpoint => endpoint.Metadata.Add( new ApiVersionNeutralAttribute() ) ); - return builder; - } - - /// - /// Indicates that the specified API version is supported by the configured endpoint. - /// - /// The extended route handler builder. - /// The major version number. - /// The optional minor version number. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder HasApiVersion( this RouteHandlerBuilder builder, int majorVersion, int? minorVersion = default, string? status = default ) => - builder.HasApiVersion( new ApiVersion( majorVersion, minorVersion, status ) ); - - /// - /// Indicates that the specified API version is supported by the configured endpoint. - /// - /// The extended route handler builder. - /// The version number. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder HasApiVersion( this RouteHandlerBuilder builder, double version, string? status = default ) => - builder.HasApiVersion( new ApiVersion( version, status ) ); - - /// - /// Indicates that the specified API version is supported by the configured endpoint. - /// - /// The extended route handler builder. - /// The version year. - /// The version month. - /// The version day. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder HasApiVersion( this RouteHandlerBuilder builder, int year, int month, int day, string? status = default ) => - builder.HasApiVersion( new ApiVersion( new DateOnly( year, month, day ), status ) ); - - /// - /// Indicates that the specified API version is supported by the configured endpoint. - /// - /// The extended route handler builder. - /// The group version. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder HasApiVersion( this RouteHandlerBuilder builder, DateOnly groupVersion, string? status = default ) => - builder.HasApiVersion( new ApiVersion( groupVersion, status ) ); - - /// - /// Indicates that the specified API version is supported by the configured endpoint. - /// - /// The extended route handler builder. - /// The supported API version implemented by the endpoint. - /// The original . - public static RouteHandlerBuilder HasApiVersion( this RouteHandlerBuilder builder, ApiVersion apiVersion ) - { - builder.Add( - endpoint => - { - var metadata = endpoint.Metadata; - metadata.Add( Convention.HasApiVersion( apiVersion ) ); - AdvertiseInApiVersionSet( metadata, apiVersion ); - } ); - - return builder; - } - - /// - /// Indicates that the specified API version is deprecated by the configured endpoint. - /// - /// The extended route handler builder. - /// The major version number. - /// The optional minor version number. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder HasDeprecatedApiVersion( this RouteHandlerBuilder builder, int majorVersion, int? minorVersion = default, string? status = default ) => - builder.HasDeprecatedApiVersion( new ApiVersion( majorVersion, minorVersion, status ) ); - - /// - /// Indicates that the specified API version is deprecated by the configured endpoint. - /// - /// The extended route handler builder. - /// The version number. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder HasDeprecatedApiVersion( this RouteHandlerBuilder builder, double version, string? status = default ) => - builder.HasDeprecatedApiVersion( new ApiVersion( version, status ) ); - - /// - /// Indicates that the specified API version is deprecated by the configured endpoint. - /// - /// The extended route handler builder. - /// The version year. - /// The version month. - /// The version day. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder HasDeprecatedApiVersion( this RouteHandlerBuilder builder, int year, int month, int day, string? status = default ) => - builder.HasDeprecatedApiVersion( new ApiVersion( new DateOnly( year, month, day ), status ) ); - - /// - /// Indicates that the specified API version is deprecated by the configured endpoint. - /// - /// The extended route handler builder. - /// The group version. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder HasDeprecatedApiVersion( this RouteHandlerBuilder builder, DateOnly groupVersion, string? status = default ) => - builder.HasDeprecatedApiVersion( new ApiVersion( groupVersion, status ) ); - - /// - /// Indicates that the specified API version is deprecated by the configured endpoint. - /// - /// The extended route handler builder. - /// The deprecated API version implemented by the endpoint. - /// The original . - public static RouteHandlerBuilder HasDeprecatedApiVersion( this RouteHandlerBuilder builder, ApiVersion apiVersion ) - { - builder.Add( - endpoint => - { - var metadata = endpoint.Metadata; - metadata.Add( Convention.HasDeprecatedApiVersion( apiVersion ) ); - AdvertiseDeprecatedInApiVersionSet( metadata, apiVersion ); - } ); - - return builder; - } - - /// - /// Indicates that the specified API version is advertised by the configured endpoint. - /// - /// The extended route handler builder. - /// The major version number. - /// The optional minor version number. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder AdvertisesApiVersion( this RouteHandlerBuilder builder, int majorVersion, int? minorVersion = default, string? status = default ) => - builder.AdvertisesApiVersion( new ApiVersion( majorVersion, minorVersion, status ) ); - - /// - /// Indicates that the specified API version is advertised by the configured endpoint. - /// - /// The extended route handler builder. - /// The version number. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder AdvertisesApiVersion( this RouteHandlerBuilder builder, double version, string? status = default ) => - builder.AdvertisesApiVersion( new ApiVersion( version, status ) ); - - /// - /// Indicates that the specified API version is advertised by the configured endpoint. - /// - /// The extended route handler builder. - /// The version year. - /// The version month. - /// The version day. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder AdvertisesApiVersion( this RouteHandlerBuilder builder, int year, int month, int day, string? status = default ) => - builder.AdvertisesApiVersion( new ApiVersion( new DateOnly( year, month, day ), status ) ); - - /// - /// Indicates that the specified API version is advertised by the configured endpoint. - /// - /// The extended route handler builder. - /// The group version. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder AdvertisesApiVersion( this RouteHandlerBuilder builder, DateOnly groupVersion, string? status = default ) => - builder.AdvertisesApiVersion( new ApiVersion( groupVersion, status ) ); - - /// - /// Indicates that the specified API version is advertised by the configured endpoint. - /// - /// The extended route handler builder. - /// The advertised API version not directly implemented by the endpoint. - /// The original . - public static RouteHandlerBuilder AdvertisesApiVersion( this RouteHandlerBuilder builder, ApiVersion apiVersion ) - { - builder.Add( - endpoint => - { - var metadata = endpoint.Metadata; - metadata.Add( Convention.AdvertisesApiVersion( apiVersion ) ); - AdvertiseInApiVersionSet( metadata, apiVersion ); - } ); - - return builder; - } - - /// - /// Indicates that the specified API version is advertised and deprecated by the configured endpoint. - /// - /// The extended route handler builder. - /// The major version number. - /// The optional minor version number. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder AdvertisesDeprecatedApiVersion( this RouteHandlerBuilder builder, int majorVersion, int? minorVersion = default, string? status = default ) => - builder.AdvertisesDeprecatedApiVersion( new ApiVersion( majorVersion, minorVersion, status ) ); - - /// - /// Indicates that the specified API version is advertised and deprecated by the configured endpoint. - /// - /// The extended route handler builder. - /// The version number. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder AdvertisesDeprecatedApiVersion( this RouteHandlerBuilder builder, double version, string? status = default ) => - builder.AdvertisesDeprecatedApiVersion( new ApiVersion( version, status ) ); - - /// - /// Indicates that the specified API version is advertised and deprecated by the configured endpoint. - /// - /// The extended route handler builder. - /// The version year. - /// The version month. - /// The version day. - /// The version status. - /// The original . - public static RouteHandlerBuilder AdvertisesDeprecatedApiVersion( this RouteHandlerBuilder builder, int year, int month, int day, string? status = default ) => - builder.AdvertisesDeprecatedApiVersion( new ApiVersion( new DateOnly( year, month, day ), status ) ); - - /// - /// Indicates that the specified API version is advertised and deprecated by the configured endpoint. - /// - /// The extended route handler builder. - /// The group version. - /// The optional version status. - /// The original . - public static RouteHandlerBuilder AdvertisesDeprecatedApiVersion( this RouteHandlerBuilder builder, DateOnly groupVersion, string? status = default ) => - builder.AdvertisesDeprecatedApiVersion( new ApiVersion( groupVersion, status ) ); - - /// - /// Indicates that the specified API version is advertised and deprecated by the configured endpoint. - /// - /// The extended route handler builder. - /// The advertised, but deprecated API version not directly implemented by the endpoint. - /// The original . - public static RouteHandlerBuilder AdvertisesDeprecatedApiVersion( this RouteHandlerBuilder builder, ApiVersion apiVersion ) - { - builder.Add( - endpoint => - { - var metadata = endpoint.Metadata; - metadata.Add( Convention.AdvertisesDeprecatedApiVersion( apiVersion ) ); - AdvertiseDeprecatedInApiVersionSet( metadata, apiVersion ); - } ); - - return builder; - } - - /// - /// Indicates that the endpoint will report its API versions. - /// - /// The extended route handler builder. - /// The original . - public static RouteHandlerBuilder ReportApiVersions( this RouteHandlerBuilder builder ) - { - builder.Add( endpoint => endpoint.Metadata.Add( Convention.ReportApiVersions ) ); - return builder; - } - - private static void AdvertiseInApiVersionSet( IList metadata, ApiVersion apiVersion ) - { - for ( var i = metadata.Count - 1; i >= 0; i-- ) - { - if ( metadata[i] is ApiVersionSet versionSet ) - { - versionSet.AdvertisesApiVersion( apiVersion ); - break; - } - } - } - - private static void AdvertiseDeprecatedInApiVersionSet( IList metadata, ApiVersion apiVersion ) - { - for ( var i = metadata.Count - 1; i >= 0; i-- ) - { - if ( metadata[i] is ApiVersionSet versionSet ) - { - versionSet.AdvertisesDeprecatedApiVersion( apiVersion ); - break; - } - } - } - - private sealed class SingleItemReadOnlyList : IReadOnlyList - { - private readonly ApiVersion item; - - internal SingleItemReadOnlyList( ApiVersion item ) => this.item = item; - - public ApiVersion this[int index] => index == 0 ? item : throw new IndexOutOfRangeException(); - - public int Count => 1; - - public IEnumerator GetEnumerator() - { - yield return item; - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - } - - private sealed class ReportApiVersionsConvention : IReportApiVersions - { - public ApiVersionMapping Mapping => ApiVersionMapping.None; - - public void Report( HttpResponse response, ApiVersionModel apiVersionModel ) { } - } - - private sealed class Convention : IApiVersionProvider - { - private static ReportApiVersionsConvention? reportApiVersions; - - private Convention( ApiVersion version, ApiVersionProviderOptions options ) - { - Versions = new SingleItemReadOnlyList( version ); - Options = options; - } - - public ApiVersionProviderOptions Options { get; } - - public IReadOnlyList Versions { get; } - - internal static IReportApiVersions ReportApiVersions => reportApiVersions ??= new(); - - internal static Convention HasApiVersion( ApiVersion version ) => new( version, None ); - - internal static Convention HasDeprecatedApiVersion( ApiVersion version ) => new( version, Deprecated ); - - internal static Convention MapToApiVersion( ApiVersion version ) => new( version, Mapped ); - - internal static Convention AdvertisesApiVersion( ApiVersion version ) => new( version, Advertised ); - - internal static Convention AdvertisesDeprecatedApiVersion( ApiVersion version ) => new( version, Advertised | Deprecated ); - } -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointRouteBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointRouteBuilder.cs index 5971fe68..12fe750e 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointRouteBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointRouteBuilder.cs @@ -76,6 +76,11 @@ internal ServiceProviderDecorator( IServiceProvider decorated, ApiVersionSetBuil return versionSet ??= versionSetBuilder.Build(); } + if ( typeof( ApiVersionSetBuilder ).Equals( serviceType ) ) + { + return versionSetBuilder; + } + return decorated.GetService( serviceType ); } } @@ -152,7 +157,7 @@ private void CollateGroupApiVersions() for ( var k = 0; k < versions.Count; k++ ) { - add( versions[i] ); + add( versions[k] ); } } } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs index 81dce11a..b2325708 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs @@ -69,6 +69,24 @@ internal static string ApiVersionUnspecified { } } + /// + /// Looks up a localized string similar to An API group cannot be mapped as a nested group.. + /// + internal static string CannotNestApiGroup { + get { + return ResourceManager.GetString("CannotNestApiGroup", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A grouped API version set cannot be nested under another group.. + /// + internal static string CannotNestVersionSet { + get { + return ResourceManager.GetString("CannotNestVersionSet", resourceCulture); + } + } + /// /// Looks up a localized string similar to Conventions cannot be added after building the endpoint.. /// @@ -78,6 +96,15 @@ internal static string ConventionAddedAfterEndpointBuilt { } } + /// + /// Looks up a localized string similar to The endpoint '{0}' does not have an associated API version set. Are you missing a call to {1} or {2}.. + /// + internal static string NoVersionSet { + get { + return ResourceManager.GetString("NoVersionSet", resourceCulture); + } + } + /// /// Looks up a localized string similar to The request type was not configured.. /// diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx index 44f12e69..3ac7111f 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx @@ -120,9 +120,18 @@ An API version is required, but was not specified. + + An API group cannot be mapped as a nested group. + + + A grouped API version set cannot be nested under another group. + Conventions cannot be added after building the endpoint. + + The endpoint '{0}' does not have an associated API version set. Are you missing a call to {1} or {2}. + The request type was not configured. diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs index f5856939..d344e28a 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs @@ -7,6 +7,7 @@ namespace Asp.Versioning.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Options; +using static Asp.Versioning.ApiVersionProviderOptions; public class IEndpointConventionBuilderExtensionsTest { @@ -250,6 +251,435 @@ public void with_api_version_set_should_collate_across_grouped_endpoints() .BeEquivalentTo( ApiVersionMetadata.Neutral ); } + [Fact] + public void report_api_versions_should_add_convention() + { + // arrange + var conventions = new Mock(); + var reportApiVersions = default( IReportApiVersions ); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => + { + var endpoint = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + endpoint.Metadata.Add( versionSet ); + callback( endpoint ); + reportApiVersions = endpoint.Metadata.OfType().First(); + } ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + route.ReportApiVersions(); + + // assert + reportApiVersions.Should().NotBeNull(); + } + + [Fact] + public void is_api_version_neutral_should_add_convention() + { + // arrange + var conventions = new Mock(); + var versionNeutral = default( IApiVersionNeutral ); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => + { + var endpoint = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + endpoint.Metadata.Add( versionSet ); + callback( endpoint ); + versionNeutral = endpoint.Metadata.OfType().First(); + } ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + route.IsApiVersionNeutral(); + + // assert + versionNeutral.Should().NotBeNull(); + } + + [Fact] + public void has_api_version_should_add_convention() + { + // arrange + var conventions = new Mock(); + var provider = default( IApiVersionProvider ); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => + { + var endpoint = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + endpoint.Metadata.Add( versionSet ); + callback( endpoint ); + provider = endpoint.Metadata.OfType().First(); + } ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + route.HasApiVersion( 1.0 ); + + // assert + provider.Should().BeEquivalentTo( + new + { + Options = None, + Versions = new[] { new ApiVersion( 1.0 ) }, + } ); + } + + [Fact] + public void has_api_version_should_propagate_to_version_set() + { + // arrange + var conventions = new Mock(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => + { + var endpoint = Mock.Of(); + endpoint.Metadata.Add( versionSet ); + callback( endpoint ); + } ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + route.HasApiVersion( 1.0 ); + + // assert + versionSet.Build( new() ).SupportedApiVersions.Single().Should().Be( new ApiVersion( 1.0 ) ); + } + + [Fact] + public void has_deprecated_api_version_should_add_convention() + { + // arrange + var conventions = new Mock(); + var provider = default( IApiVersionProvider ); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => + { + var endpoint = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + endpoint.Metadata.Add( versionSet ); + callback( endpoint ); + provider = endpoint.Metadata.OfType().First(); + } ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + route.HasDeprecatedApiVersion( 0.9 ); + + // assert + provider.Should().BeEquivalentTo( + new + { + Options = Deprecated, + Versions = new[] { new ApiVersion( 0.9 ) }, + } ); + } + + [Fact] + public void has_deprecated_api_version_should_propagate_to_version_set() + { + // arrange + var conventions = new Mock(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => + { + var endpoint = Mock.Of(); + endpoint.Metadata.Add( versionSet ); + callback( endpoint ); + } ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + route.HasDeprecatedApiVersion( 0.9 ); + + // assert + versionSet.Build( new() ).DeprecatedApiVersions.Single().Should().Be( new ApiVersion( 0.9 ) ); + } + + [Fact] + public void advertises_api_version_should_add_convention() + { + // arrange + var conventions = new Mock(); + var provider = default( IApiVersionProvider ); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => + { + var endpoint = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + endpoint.Metadata.Add( versionSet ); + callback( endpoint ); + provider = endpoint.Metadata.OfType().First(); + } ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + route.AdvertisesApiVersion( 42.0 ); + + // assert + provider.Should().BeEquivalentTo( + new + { + Options = Advertised, + Versions = new[] { new ApiVersion( 42.0 ) }, + } ); + } + + [Fact] + public void advertises_api_version_should_propagate_to_version_set() + { + // arrange + var conventions = new Mock(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => + { + var endpoint = Mock.Of(); + endpoint.Metadata.Add( versionSet ); + callback( endpoint ); + } ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + route.AdvertisesApiVersion( 42.0 ); + + // assert + versionSet.Build( new() ).SupportedApiVersions.Single().Should().Be( new ApiVersion( 42.0 ) ); + } + + [Fact] + public void advertises_deprecated_api_version_should_add_convention() + { + // arrange + var conventions = new Mock(); + var provider = default( IApiVersionProvider ); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => + { + var endpoint = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + endpoint.Metadata.Add( versionSet ); + callback( endpoint ); + provider = endpoint.Metadata.OfType().First(); + } ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + route.AdvertisesDeprecatedApiVersion( 42.0, "rc" ); + + // assert + provider.Should().BeEquivalentTo( + new + { + Options = Advertised | Deprecated, + Versions = new[] { new ApiVersion( 42.0, "rc" ) }, + } ); + } + + [Fact] + public void advertises_deprecated_api_version_should_propagate_to_version_set() + { + // arrange + var conventions = new Mock(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => + { + var endpoint = Mock.Of(); + endpoint.Metadata.Add( versionSet ); + callback( endpoint ); + } ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + route.AdvertisesDeprecatedApiVersion( 42.0, "rc" ); + + // assert + versionSet.Build( new() ).DeprecatedApiVersions.Single().Should().Be( new ApiVersion( 42.0, "rc" ) ); + } + + [Fact] + public void map_to_api_version_should_add_convention() + { + // arrange + var conventions = new Mock(); + var provider = default( IApiVersionProvider ); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => + { + var endpoint = Mock.Of(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + endpoint.Metadata.Add( versionSet ); + callback( endpoint ); + provider = endpoint.Metadata.OfType().First(); + } ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + route.MapToApiVersion( 2.0 ); + + // assert + provider.Should().BeEquivalentTo( + new + { + Options = Mapped, + Versions = new[] { new ApiVersion( 2.0 ) }, + } ); + } + + [Fact] + public void map_to_api_version_should_throw_exception_without_version_set() + { + // arrange + var conventions = new Mock(); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => callback( Mock.Of() ) ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + var mapToApiVersion = () => route.MapToApiVersion( 2.0 ); + + // assert + mapToApiVersion.Should().Throw(); + } + + [Fact] + public void has_api_version_should_throw_exception_without_version_set() + { + // arrange + var conventions = new Mock(); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => callback( Mock.Of() ) ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + var hasApiVersion = () => route.HasApiVersion( 2.0 ); + + // assert + hasApiVersion.Should().Throw(); + } + + [Fact] + public void has_deprecated_api_version_should_throw_exception_without_version_set() + { + // arrange + var conventions = new Mock(); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => callback( Mock.Of() ) ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + var hasDeprecatedApiVersion = () => route.HasDeprecatedApiVersion( 2.0 ); + + // assert + hasDeprecatedApiVersion.Should().Throw(); + } + + [Fact] + public void advertises_api_version_should_throw_exception_without_version_set() + { + // arrange + var conventions = new Mock(); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => callback( Mock.Of() ) ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + var advertisesApiVersion = () => route.AdvertisesApiVersion( 2.0 ); + + // assert + advertisesApiVersion.Should().Throw(); + } + + [Fact] + public void advertises_deprecated_api_version_should_throw_exception_without_version_set() + { + // arrange + var conventions = new Mock(); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => callback( Mock.Of() ) ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + var advertisesDeprecatedApiVersion = () => route.AdvertisesDeprecatedApiVersion( 2.0 ); + + // assert + advertisesDeprecatedApiVersion.Should().Throw(); + } + + [Fact] + public void is_api_version_neutral_should_throw_exception_without_version_set() + { + // arrange + var conventions = new Mock(); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => callback( Mock.Of() ) ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + var isApiVersionNeutral = () => route.IsApiVersionNeutral(); + + // assert + isApiVersionNeutral.Should().Throw(); + } + + [Fact] + public void reports_api_versions_should_throw_exception_without_version_set() + { + // arrange + var conventions = new Mock(); + + conventions.Setup( b => b.Add( It.IsAny>() ) ) + .Callback( ( Action callback ) => callback( Mock.Of() ) ); + + var route = new RouteHandlerBuilder( new[] { conventions.Object } ); + + // act + var reportsApiVersions = () => route.ReportApiVersions(); + + // assert + reportsApiVersions.Should().Throw(); + } + private sealed class MockServiceProvider : IServiceProvider { private readonly IOptions options = Options.Create( new ApiVersioningOptions() ); diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointRouteBuilderExtensionsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointRouteBuilderExtensionsTest.cs index 0c38fc7c..f2d91552 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointRouteBuilderExtensionsTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointRouteBuilderExtensionsTest.cs @@ -29,4 +29,85 @@ public void new_api_version_set_should_use_name() // assert versionSet.Name.Should().Be( "Test" ); } + + [Fact] + public void with_api_version_set_should_not_be_allowed_multiple_times() + { + // arrange + var builder = WebApplication.CreateBuilder(); + var services = builder.Services; + + services.AddControllers(); + services.AddApiVersioning(); + + var app = builder.Build(); + var group = app.MapGroup( "Test" ); + + // act + var withApiVersionSet = () => group.WithApiVersionSet().WithApiVersionSet(); + + // assert + withApiVersionSet.Should().Throw(); + } + + [Fact] + public void with_api_version_set_should_not_allow_nesting() + { + // arrange + var builder = WebApplication.CreateBuilder(); + var services = builder.Services; + + services.AddControllers(); + services.AddApiVersioning(); + + var app = builder.Build(); + var g1 = app.MapGroup( "Root" ).WithApiVersionSet(); + var g2 = g1.MapGroup( "Test" ); + + // act + var withApiVersionSet = () => g2.WithApiVersionSet(); + + // assert + withApiVersionSet.Should().Throw(); + } + + [Fact] + public void map_api_group_should_not_be_allowed_multiple_times() + { + // arrange + var builder = WebApplication.CreateBuilder(); + var services = builder.Services; + + services.AddControllers(); + services.AddApiVersioning(); + + var app = builder.Build(); + + // act + var mapApiGroup = () => app.MapApiGroup().MapApiGroup(); + + // assert + mapApiGroup.Should().Throw(); + } + + [Fact] + public void map_api_group_should_not_allow_nesting() + { + // arrange + var builder = WebApplication.CreateBuilder(); + var services = builder.Services; + + services.AddControllers(); + services.AddApiVersioning(); + + var app = builder.Build(); + var g1 = app.MapApiGroup(); + var g2 = g1.MapGroup( "Test" ); + + // act + var mapApiGroup = () => g2.MapApiGroup(); + + // assert + mapApiGroup.Should().Throw(); + } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/RouteHandlerBuilderExtensionsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/RouteHandlerBuilderExtensionsTest.cs deleted file mode 100644 index d2f9c1a1..00000000 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/RouteHandlerBuilderExtensionsTest.cs +++ /dev/null @@ -1,298 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -using Microsoft.AspNetCore.Builder; -using static Asp.Versioning.ApiVersionProviderOptions; - -public class RouteHandlerBuilderExtensionsTest -{ - [Fact] - public void report_api_versions_should_add_convention() - { - // arrange - var conventions = new Mock(); - var reportApiVersions = default( IReportApiVersions ); - - conventions.Setup( b => b.Add( It.IsAny>() ) ) - .Callback( ( Action callback ) => - { - var endpoint = Mock.Of(); - callback( endpoint ); - reportApiVersions = endpoint.Metadata.OfType().First(); - } ); - - var route = new RouteHandlerBuilder( new[] { conventions.Object } ); - - // act - route.ReportApiVersions(); - - // assert - reportApiVersions.Should().NotBeNull(); - } - - [Fact] - public void is_api_version_neutral_should_add_convention() - { - // arrange - var conventions = new Mock(); - var versionNeutral = default( IApiVersionNeutral ); - - conventions.Setup( b => b.Add( It.IsAny>() ) ) - .Callback( ( Action callback ) => - { - var endpoint = Mock.Of(); - callback( endpoint ); - versionNeutral = endpoint.Metadata.OfType().First(); - } ); - - var route = new RouteHandlerBuilder( new[] { conventions.Object } ); - - // act - route.IsApiVersionNeutral(); - - // assert - versionNeutral.Should().NotBeNull(); - } - - [Fact] - public void has_api_version_should_add_convention() - { - // arrange - var conventions = new Mock(); - var provider = default( IApiVersionProvider ); - - conventions.Setup( b => b.Add( It.IsAny>() ) ) - .Callback( ( Action callback ) => - { - var endpoint = Mock.Of(); - callback( endpoint ); - provider = endpoint.Metadata.OfType().First(); - } ); - - var route = new RouteHandlerBuilder( new[] { conventions.Object } ); - - // act - route.HasApiVersion( 1.0 ); - - // assert - provider.Should().BeEquivalentTo( - new - { - Options = None, - Versions = new[] { new ApiVersion( 1.0 ) }, - } ); - } - - [Fact] - public void has_api_version_should_propagate_to_version_set() - { - // arrange - var conventions = new Mock(); - var versionSet = new ApiVersionSetBuilder( default ).Build(); - - conventions.Setup( b => b.Add( It.IsAny>() ) ) - .Callback( ( Action callback ) => - { - var endpoint = Mock.Of(); - endpoint.Metadata.Add( versionSet ); - callback( endpoint ); - } ); - - var route = new RouteHandlerBuilder( new[] { conventions.Object } ); - - // act - route.HasApiVersion( 1.0 ); - - // assert - versionSet.Build( new() ).SupportedApiVersions.Single().Should().Be( new ApiVersion( 1.0 ) ); - } - - [Fact] - public void has_deprecated_api_version_should_add_convention() - { - // arrange - var conventions = new Mock(); - var provider = default( IApiVersionProvider ); - - conventions.Setup( b => b.Add( It.IsAny>() ) ) - .Callback( ( Action callback ) => - { - var endpoint = Mock.Of(); - callback( endpoint ); - provider = endpoint.Metadata.OfType().First(); - } ); - - var route = new RouteHandlerBuilder( new[] { conventions.Object } ); - - // act - route.HasDeprecatedApiVersion( 0.9 ); - - // assert - provider.Should().BeEquivalentTo( - new - { - Options = Deprecated, - Versions = new[] { new ApiVersion( 0.9 ) }, - } ); - } - - [Fact] - public void has_deprecated_api_version_should_propagate_to_version_set() - { - // arrange - var conventions = new Mock(); - var versionSet = new ApiVersionSetBuilder( default ).Build(); - - conventions.Setup( b => b.Add( It.IsAny>() ) ) - .Callback( ( Action callback ) => - { - var endpoint = Mock.Of(); - endpoint.Metadata.Add( versionSet ); - callback( endpoint ); - } ); - - var route = new RouteHandlerBuilder( new[] { conventions.Object } ); - - // act - route.HasDeprecatedApiVersion( 0.9 ); - - // assert - versionSet.Build( new() ).DeprecatedApiVersions.Single().Should().Be( new ApiVersion( 0.9 ) ); - } - - [Fact] - public void advertises_api_version_should_add_convention() - { - // arrange - var conventions = new Mock(); - var provider = default( IApiVersionProvider ); - - conventions.Setup( b => b.Add( It.IsAny>() ) ) - .Callback( ( Action callback ) => - { - var endpoint = Mock.Of(); - callback( endpoint ); - provider = endpoint.Metadata.OfType().First(); - } ); - - var route = new RouteHandlerBuilder( new[] { conventions.Object } ); - - // act - route.AdvertisesApiVersion( 42.0 ); - - // assert - provider.Should().BeEquivalentTo( - new - { - Options = Advertised, - Versions = new[] { new ApiVersion( 42.0 ) }, - } ); - } - - [Fact] - public void advertises_api_version_should_propagate_to_version_set() - { - // arrange - var conventions = new Mock(); - var versionSet = new ApiVersionSetBuilder( default ).Build(); - - conventions.Setup( b => b.Add( It.IsAny>() ) ) - .Callback( ( Action callback ) => - { - var endpoint = Mock.Of(); - endpoint.Metadata.Add( versionSet ); - callback( endpoint ); - } ); - - var route = new RouteHandlerBuilder( new[] { conventions.Object } ); - - // act - route.AdvertisesApiVersion( 42.0 ); - - // assert - versionSet.Build( new() ).SupportedApiVersions.Single().Should().Be( new ApiVersion( 42.0 ) ); - } - - [Fact] - public void advertises_deprecated_api_version_should_add_convention() - { - // arrange - var conventions = new Mock(); - var provider = default( IApiVersionProvider ); - - conventions.Setup( b => b.Add( It.IsAny>() ) ) - .Callback( ( Action callback ) => - { - var endpoint = Mock.Of(); - callback( endpoint ); - provider = endpoint.Metadata.OfType().First(); - } ); - - var route = new RouteHandlerBuilder( new[] { conventions.Object } ); - - // act - route.AdvertisesDeprecatedApiVersion( 42.0, "rc" ); - - // assert - provider.Should().BeEquivalentTo( - new - { - Options = Advertised | Deprecated, - Versions = new[] { new ApiVersion( 42.0, "rc" ) }, - } ); - } - - [Fact] - public void advertises_deprecated_api_version_should_propagate_to_version_set() - { - // arrange - var conventions = new Mock(); - var versionSet = new ApiVersionSetBuilder( default ).Build(); - - conventions.Setup( b => b.Add( It.IsAny>() ) ) - .Callback( ( Action callback ) => - { - var endpoint = Mock.Of(); - endpoint.Metadata.Add( versionSet ); - callback( endpoint ); - } ); - - var route = new RouteHandlerBuilder( new[] { conventions.Object } ); - - // act - route.AdvertisesDeprecatedApiVersion( 42.0, "rc" ); - - // assert - versionSet.Build( new() ).DeprecatedApiVersions.Single().Should().Be( new ApiVersion( 42.0, "rc" ) ); - } - - [Fact] - public void map_to_api_version_should_add_convention() - { - // arrange - var conventions = new Mock(); - var provider = default( IApiVersionProvider ); - - conventions.Setup( b => b.Add( It.IsAny>() ) ) - .Callback( ( Action callback ) => - { - var endpoint = Mock.Of(); - callback( endpoint ); - provider = endpoint.Metadata.OfType().First(); - } ); - - var route = new RouteHandlerBuilder( new[] { conventions.Object } ); - - // act - route.MapToApiVersion( 2.0 ); - - // assert - provider.Should().BeEquivalentTo( - new - { - Options = Mapped, - Versions = new[] { new ApiVersion( 2.0 ) }, - } ); - } -} \ No newline at end of file From 78bee23faed1700776fcf01f58d8450bede2b581 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Wed, 16 Nov 2022 11:10:42 -0800 Subject: [PATCH 2/6] Add more validation for WithApiVersionSet on an endpoint --- .../Builder/EndpointBuilderFinalizer.cs | 6 +- .../IEndpointConventionBuilderExtensions.cs | 23 +++++++- .../src/Asp.Versioning.Http/SR.Designer.cs | 9 +++ .../WebApi/src/Asp.Versioning.Http/SR.resx | 3 + ...EndpointConventionBuilderExtensionsTest.cs | 56 +++++++++++++++++-- 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs index c944377f..027dabf9 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs @@ -120,18 +120,16 @@ private static bool ReportApiVersions( IList metadata ) private static ApiVersionSet? GetApiVersionSet( IList metadata ) { - var result = default( ApiVersionSet ); - for ( var i = metadata.Count - 1; i >= 0; i-- ) { if ( metadata[i] is ApiVersionSet set ) { - result ??= set; metadata.RemoveAt( i ); + return set; } } - return result; + return default; } private static bool TryGetApiVersions( IList metadata, out ApiVersionBuckets buckets ) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs index f774fac2..df8ea86f 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs @@ -32,7 +32,7 @@ public static TBuilder WithApiVersionSet( throw new ArgumentNullException( nameof( apiVersionSet ) ); } - builder.Add( endpoint => endpoint.Metadata.Add( apiVersionSet ) ); + builder.Add( endpoint => AddWithValidation( endpoint, apiVersionSet ) ); builder.Finally( EndpointBuilderFinalizer.FinalizeEndpoints ); return builder; @@ -393,6 +393,27 @@ public static TBuilder ReportApiVersions( this TBuilder builder ) return builder; } + private static void AddWithValidation( EndpointBuilder builder, ApiVersionSet versionSet ) + { + var metadata = builder.Metadata; + var grouped = builder.ApplicationServices.GetService( typeof( ApiVersionSetBuilder ) ) is not null; + + if ( grouped ) + { + throw new InvalidOperationException( SR.MultipleVersionSets ); + } + + for ( var i = 0; i < metadata.Count; i++ ) + { + if ( metadata[i] is ApiVersionSet ) + { + throw new InvalidOperationException( SR.MultipleVersionSets ); + } + } + + metadata.Add( versionSet ); + } + private static void AdvertiseInApiVersionSet( IList metadata, ApiVersion apiVersion ) { for ( var i = metadata.Count - 1; i >= 0; i-- ) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs index b2325708..bdfddbdd 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.Designer.cs @@ -96,6 +96,15 @@ internal static string ConventionAddedAfterEndpointBuilt { } } + /// + /// Looks up a localized string similar to An endpoint cannot apply multiple API version sets.. + /// + internal static string MultipleVersionSets { + get { + return ResourceManager.GetString("MultipleVersionSets", resourceCulture); + } + } + /// /// Looks up a localized string similar to The endpoint '{0}' does not have an associated API version set. Are you missing a call to {1} or {2}.. /// diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx index 3ac7111f..8ebb518c 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/SR.resx @@ -129,6 +129,9 @@ Conventions cannot be added after building the endpoint. + + An endpoint cannot apply multiple API version sets. + The endpoint '{0}' does not have an associated API version set. Are you missing a call to {1} or {2}. diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs index d344e28a..44ee61ea 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Http.Tests/Builder/IEndpointConventionBuilderExtensionsTest.cs @@ -6,6 +6,7 @@ namespace Asp.Versioning.Builder; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using static Asp.Versioning.ApiVersionProviderOptions; @@ -251,6 +252,56 @@ public void with_api_version_set_should_collate_across_grouped_endpoints() .BeEquivalentTo( ApiVersionMetadata.Neutral ); } + [Fact] + public void with_api_version_set_should_not_be_allowed_multiple_times() + { + // arrange + var builder = WebApplication.CreateBuilder(); + var services = builder.Services; + + services.AddControllers(); + services.AddApiVersioning(); + + var app = builder.Build(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + var get = app.MapGet( "/", () => Results.Ok() ); + IEndpointRouteBuilder endpoints = app; + + get.WithApiVersionSet( versionSet ); + get.WithApiVersionSet( versionSet ); + + // act + var build = () => endpoints.DataSources.Single().Endpoints; + + // assert + build.Should().Throw(); + } + + [Fact] + public void with_api_version_set_should_not_override_existing_metadata() + { + // arrange + var builder = WebApplication.CreateBuilder(); + var services = builder.Services; + + services.AddControllers(); + services.AddApiVersioning(); + + var app = builder.Build(); + var versionSet = new ApiVersionSetBuilder( default ).Build(); + var group = app.MapApiGroup(); + var get = group.MapGet( "/", () => Results.Ok() ); + IEndpointRouteBuilder endpoints = app; + + get.WithApiVersionSet( versionSet ); + + // act + var build = () => endpoints.DataSources.Single().Endpoints; + + // assert + build.Should().Throw(); + } + [Fact] public void report_api_versions_should_add_convention() { @@ -696,11 +747,6 @@ public object GetService( Type serviceType ) return options.Value.ApiVersionReader; } - if ( typeof( IApiVersionSetBuilderFactory ) == serviceType ) - { - return new DefaultApiVersionSetBuilderFactory(); - } - return null; } } From 85fca0c63a91c1990bd15bd02bb61e4b21684883 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Wed, 16 Nov 2022 11:11:20 -0800 Subject: [PATCH 3/6] Refactor factories to use injectable delegates --- .../Builder/ApiVersionSetBuilderFactory.cs | 10 +++++++ .../DefaultApiVersionSetBuilderFactory.cs | 19 -------------- .../Builder/IApiVersionSetBuilderFactory.cs | 16 ------------ .../IEndpointRouteBuilderExtensions.cs | 26 ++++++++++++++----- .../VersionedEndpointRouteBuilderFactory.cs | 19 ++++++++++++++ .../IServiceCollectionExtensions.cs | 1 - 6 files changed, 48 insertions(+), 43 deletions(-) create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/ApiVersionSetBuilderFactory.cs delete mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/DefaultApiVersionSetBuilderFactory.cs delete mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IApiVersionSetBuilderFactory.cs create mode 100644 src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointRouteBuilderFactory.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/ApiVersionSetBuilderFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/ApiVersionSetBuilderFactory.cs new file mode 100644 index 00000000..7d5960dd --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/ApiVersionSetBuilderFactory.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Builder; + +/// +/// Creates and returns a new API version set builder. +/// +/// The name of the API associated with the builder, if any. +/// A new API version set builder. +public delegate ApiVersionSetBuilder ApiVersionSetBuilderFactory( string? name = default ); \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/DefaultApiVersionSetBuilderFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/DefaultApiVersionSetBuilderFactory.cs deleted file mode 100644 index c8f879a1..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/DefaultApiVersionSetBuilderFactory.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -/// -/// Represents the default API version set builder factory. -/// -public class DefaultApiVersionSetBuilderFactory : IApiVersionSetBuilderFactory -{ - /// - public ApiVersionSetBuilder Create( string? name = default ) => CreateInstance( name ); - - /// - /// Creates and returns a new builder instance. - /// - /// The optional name associated with the builder. - /// A new API version set builder. - protected virtual ApiVersionSetBuilder CreateInstance( string? name ) => new( name ); -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IApiVersionSetBuilderFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IApiVersionSetBuilderFactory.cs deleted file mode 100644 index 2b96db7e..00000000 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IApiVersionSetBuilderFactory.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning.Builder; - -/// -/// Defines the behavior of a factory to create API version set builders. -/// -public interface IApiVersionSetBuilderFactory -{ - /// - /// Creates and returns a new API version set builder. - /// - /// The name of the API associated with the builder, if any. - /// A new API version set builder. - ApiVersionSetBuilder Create( string? name = default ); -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointRouteBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointRouteBuilderExtensions.cs index 54270a5f..10901223 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointRouteBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointRouteBuilderExtensions.cs @@ -27,9 +27,9 @@ public static class IEndpointRouteBuilderExtensions throw new ArgumentNullException( nameof( endpoints ) ); } - var factory = endpoints.ServiceProvider.GetRequiredService(); + var create = endpoints.ServiceProvider.GetService(); - return factory.Create( name ); + return create is null ? new ApiVersionSetBuilder( name ) : create( name ); } /// @@ -52,11 +52,9 @@ public static class IEndpointRouteBuilderExtensions throw new InvalidOperationException( SR.CannotNestVersionSet ); } - var factory = builder.ServiceProvider.GetRequiredService(); - builder.Finally( EndpointBuilderFinalizer.FinalizeRoutes ); - return new VersionedEndpointRouteBuilder( builder, builder, factory.Create( name ) ); + return builder.NewVersionedEndpointRouteBuilder( builder, builder, name ); } /// @@ -79,11 +77,25 @@ public static class IEndpointRouteBuilderExtensions var group = builder.MapGroup( string.Empty ); IEndpointConventionBuilder convention = group; - var factory = builder.ServiceProvider.GetRequiredService(); convention.Finally( EndpointBuilderFinalizer.FinalizeRoutes ); - return new VersionedEndpointRouteBuilder( group, group, factory.Create( name ) ); + return builder.NewVersionedEndpointRouteBuilder( group, group, name ); + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + private static IVersionedEndpointRouteBuilder NewVersionedEndpointRouteBuilder( + this IEndpointRouteBuilder builder, + IEndpointRouteBuilder routeBuilder, + IEndpointConventionBuilder conventionBuilder, + string? name ) + { + var create = builder.ServiceProvider.GetService(); + var versionSet = builder.NewApiVersionSet( name ); + + return create is null ? + new VersionedEndpointRouteBuilder( routeBuilder, conventionBuilder, versionSet ) : + create( routeBuilder, conventionBuilder, versionSet ); } [MethodImpl( MethodImplOptions.AggressiveInlining )] diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointRouteBuilderFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointRouteBuilderFactory.cs new file mode 100644 index 00000000..04aaca1c --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointRouteBuilderFactory.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.Builder; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +/// +/// Creates and returns a new versioned endpoint route builder. +/// +/// The inner the new instance decorates. +/// The inner the new instance decorates. +/// The associated API version set builder. +/// A new instance. +[CLSCompliant( false )] +public delegate IVersionedEndpointRouteBuilder VersionedEndpointRouteBuilderFactory( + IEndpointRouteBuilder routeBuilder, + IEndpointConventionBuilder conventionBuilder, + ApiVersionSetBuilder apiVersionSetBuilder ); \ 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 4e29d063..14d52c39 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs @@ -84,7 +84,6 @@ private static void AddApiVersioningServices( IServiceCollection services ) throw new ArgumentNullException( nameof( services ) ); } - services.TryAddSingleton(); services.TryAddSingleton(); services.Add( Singleton( sp => sp.GetRequiredService>().Value.ApiVersionReader ) ); services.Add( Singleton( sp => (IApiVersionParameterSource) sp.GetRequiredService>().Value.ApiVersionReader ) ); From 5ee1aaa967df24029166cc1cd6355ddeb0797d71 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Wed, 16 Nov 2022 12:07:04 -0800 Subject: [PATCH 4/6] Clean up example --- .../WebApi/MinimalApiExample/Program.cs | 2 - .../WebApi/MinimalOpenApiExample/Program.cs | 402 +++++++++--------- 2 files changed, 201 insertions(+), 203 deletions(-) diff --git a/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs b/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs index d1ba28d2..9812a84f 100644 --- a/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs +++ b/examples/AspNetCore/WebApi/MinimalApiExample/Program.cs @@ -1,5 +1,3 @@ -using Asp.Versioning.Conventions; - var builder = WebApplication.CreateBuilder( args ); // Add services to the container. diff --git a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs index b912b0da..4afd9c8e 100644 --- a/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs +++ b/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs @@ -1,6 +1,5 @@ using ApiVersioning.Examples; using Asp.Versioning; -using Asp.Versioning.Conventions; using Microsoft.Extensions.Options; using Swashbuckle.AspNetCore.SwaggerGen; using OrderV1 = ApiVersioning.Examples.Models.V1.Order; @@ -51,216 +50,217 @@ var people = app.MapApiGroup( "People" ); // 1.0 -var o1 = orders.MapGroup( "/api/orders" ) - .HasDeprecatedApiVersion( 0.9 ) - .HasApiVersion( 1.0 ); - -o1.MapGet( "/{id:int}", ( int id ) => new OrderV1() { Id = id, Customer = "John Doe" } ) - .Produces() - .Produces( 404 ); - -o1.MapPost( "/", ( HttpRequest request, OrderV1 order ) => - { - order.Id = 42; - var scheme = request.Scheme; - var host = request.Host; - var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); - return Results.Created( location, order ); - } ) - .Accepts( "application/json" ) - .Produces( 201 ) - .Produces( 400 ) - .MapToApiVersion( 1.0 ); - -o1.MapMethods( "/{id:int}", new[] { HttpMethod.Patch.Method }, ( int id, OrderV1 order ) => Results.NoContent() ) - .Accepts( "application/json" ) - .Produces( 204 ) - .Produces( 400 ) - .Produces( 404 ) - .MapToApiVersion( 1.0 ); +var ordersV1 = orders.MapGroup( "/api/orders" ) + .HasDeprecatedApiVersion( 0.9 ) + .HasApiVersion( 1.0 ); + +ordersV1.MapGet( "/{id:int}", ( int id ) => new OrderV1() { Id = id, Customer = "John Doe" } ) + .Produces() + .Produces( 404 ); + +ordersV1.MapPost( "/", ( HttpRequest request, OrderV1 order ) => + { + order.Id = 42; + var scheme = request.Scheme; + var host = request.Host; + var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); + return Results.Created( location, order ); + } ) + .Accepts( "application/json" ) + .Produces( 201 ) + .Produces( 400 ) + .MapToApiVersion( 1.0 ); + +ordersV1.MapPatch( "/{id:int}", ( int id, OrderV1 order ) => Results.NoContent() ) + .Accepts( "application/json" ) + .Produces( 204 ) + .Produces( 400 ) + .Produces( 404 ) + .MapToApiVersion( 1.0 ); // 2.0 -var o2 = orders.MapGroup( "/api/orders" ) - .HasApiVersion( 2.0 ); - -o2.MapGet( "/", () => - new OrderV2[] - { - new(){ Id = 1, Customer = "John Doe" }, - new(){ Id = 2, Customer = "Bob Smith" }, - new(){ Id = 3, Customer = "Jane Doe", EffectiveDate = DateTimeOffset.UtcNow.AddDays( 7d ) }, - } ) - .Produces>() - .Produces( 404 ); - -o2.MapGet( "/{id:int}", ( int id ) => new OrderV2() { Id = id, Customer = "John Doe" } ) - .Produces() - .Produces( 404 ); - -o2.MapPost( "/", ( HttpRequest request, OrderV2 order ) => - { - order.Id = 42; - var scheme = request.Scheme; - var host = request.Host; - var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); - return Results.Created( location, order ); - } ) - .Accepts( "application/json" ) - .Produces( 201 ) - .Produces( 400 ); - -o2.MapMethods( "/{id:int}", new[] { HttpMethod.Patch.Method }, ( int id, OrderV2 order ) => Results.NoContent() ) - .Accepts( "application/json" ) - .Produces( 204 ) - .Produces( 400 ) - .Produces( 404 ); +var ordersV2 = orders.MapGroup( "/api/orders" ) + .HasApiVersion( 2.0 ); + +ordersV2.MapGet( "/", () => + new OrderV2[] + { + new(){ Id = 1, Customer = "John Doe" }, + new(){ Id = 2, Customer = "Bob Smith" }, + new(){ Id = 3, Customer = "Jane Doe", EffectiveDate = DateTimeOffset.UtcNow.AddDays( 7d ) }, + } ) + .Produces>() + .Produces( 404 ); + +ordersV2.MapGet( "/{id:int}", ( int id ) => new OrderV2() { Id = id, Customer = "John Doe" } ) + .Produces() + .Produces( 404 ); + +ordersV2.MapPost( "/", ( HttpRequest request, OrderV2 order ) => + { + order.Id = 42; + var scheme = request.Scheme; + var host = request.Host; + var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); + return Results.Created( location, order ); + } ) + .Accepts( "application/json" ) + .Produces( 201 ) + .Produces( 400 ); + + +ordersV2.MapPatch( "/{id:int}", ( int id, OrderV2 order ) => Results.NoContent() ) + .Accepts( "application/json" ) + .Produces( 204 ) + .Produces( 400 ) + .Produces( 404 ); // 3.0 -var o3 = orders.MapGroup( "/api/orders" ) - .HasApiVersion( 3.0 ); - -o3.MapGet( "/", () => - new OrderV3[] - { - new(){ Id = 1, Customer = "John Doe" }, - new(){ Id = 2, Customer = "Bob Smith" }, - new(){ Id = 3, Customer = "Jane Doe", EffectiveDate = DateTimeOffset.UtcNow.AddDays( 7d ) }, - } ) - .Produces>(); - -o3.MapGet( "/{id:int}", ( int id ) => new OrderV3() { Id = id, Customer = "John Doe" } ) - .Produces() - .Produces( 404 ); - -o3.MapPost( "/", ( HttpRequest request, OrderV3 order ) => - { - order.Id = 42; - var scheme = request.Scheme; - var host = request.Host; - var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); - return Results.Created( location, order ); - } ) - .Accepts( "application/json" ) - .Produces( 201 ) - .Produces( 400 ); - -o3.MapDelete( "/{id:int}", ( int id ) => Results.NoContent() ) - .Produces( 204 ); +var ordersV3 = orders.MapGroup( "/api/orders" ) + .HasApiVersion( 3.0 ); + +ordersV3.MapGet( "/", () => + new OrderV3[] + { + new(){ Id = 1, Customer = "John Doe" }, + new(){ Id = 2, Customer = "Bob Smith" }, + new(){ Id = 3, Customer = "Jane Doe", EffectiveDate = DateTimeOffset.UtcNow.AddDays( 7d ) }, + } ) + .Produces>(); + +ordersV3.MapGet( "/{id:int}", ( int id ) => new OrderV3() { Id = id, Customer = "John Doe" } ) + .Produces() + .Produces( 404 ); + +ordersV3.MapPost( "/", ( HttpRequest request, OrderV3 order ) => + { + order.Id = 42; + var scheme = request.Scheme; + var host = request.Host; + var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/api/orders/{order.Id}" ); + return Results.Created( location, order ); + } ) + .Accepts( "application/json" ) + .Produces( 201 ) + .Produces( 400 ); + +ordersV3.MapDelete( "/{id:int}", ( int id ) => Results.NoContent() ) + .Produces( 204 ); // 1.0 -var p1 = people.MapGroup( "/api/v{version:apiVersion}/people" ) - .HasDeprecatedApiVersion( 0.9 ) - .HasApiVersion( 1.0 ); - -p1.MapGet( "/{id:int}", ( int id ) => - new PersonV1() - { - Id = id, - FirstName = "John", - LastName = "Doe", - } ) - .Produces() - .Produces( 404 ); +var peopleV1 = people.MapGroup( "/api/v{version:apiVersion}/people" ) + .HasDeprecatedApiVersion( 0.9 ) + .HasApiVersion( 1.0 ); + +peopleV1.MapGet( "/{id:int}", ( int id ) => + new PersonV1() + { + Id = id, + FirstName = "John", + LastName = "Doe", + } ) + .Produces() + .Produces( 404 ); // 2.0 -var p2 = people.MapGroup( "/api/v{version:apiVersion}/people" ) - .HasApiVersion( 2.0 ); - -p2.MapGet( "/", () => - new PersonV2[] - { - new() - { - Id = 1, - FirstName = "John", - LastName = "Doe", - Email = "john.doe@somewhere.com", - }, - new() - { - Id = 2, - FirstName = "Bob", - LastName = "Smith", - Email = "bob.smith@somewhere.com", - }, - new() - { - Id = 3, - FirstName = "Jane", - LastName = "Doe", - Email = "jane.doe@somewhere.com", - }, - } ) - .Produces>(); - -p2.MapGet( "/{id:int}", ( int id ) => - new PersonV2() - { - Id = id, - FirstName = "John", - LastName = "Doe", - Email = "john.doe@somewhere.com", - } ) - .Produces() - .Produces( 404 ); +var peopleV2 = people.MapGroup( "/api/v{version:apiVersion}/people" ) + .HasApiVersion( 2.0 ); + +peopleV2.MapGet( "/", () => + new PersonV2[] + { + new() + { + Id = 1, + FirstName = "John", + LastName = "Doe", + Email = "john.doe@somewhere.com", + }, + new() + { + Id = 2, + FirstName = "Bob", + LastName = "Smith", + Email = "bob.smith@somewhere.com", + }, + new() + { + Id = 3, + FirstName = "Jane", + LastName = "Doe", + Email = "jane.doe@somewhere.com", + }, + } ) + .Produces>(); + +peopleV2.MapGet( "/{id:int}", ( int id ) => + new PersonV2() + { + Id = id, + FirstName = "John", + LastName = "Doe", + Email = "john.doe@somewhere.com", + } ) + .Produces() + .Produces( 404 ); // 3.0 -var p3 = people.MapGroup( "/api/v{version:apiVersion}/people" ) - .HasApiVersion( 3.0 ); - -p3.MapGet( "/", () => - new PersonV3[] - { - new() - { - Id = 1, - FirstName = "John", - LastName = "Doe", - Email = "john.doe@somewhere.com", - Phone = "555-987-1234", - }, - new() - { - Id = 2, - FirstName = "Bob", - LastName = "Smith", - Email = "bob.smith@somewhere.com", - Phone = "555-654-4321", - }, - new() - { - Id = 3, - FirstName = "Jane", - LastName = "Doe", - Email = "jane.doe@somewhere.com", - Phone = "555-789-3456", - }, - } ) - .Produces>(); - -p3.MapGet( "/{id:int}", ( int id ) => - new PersonV3() - { - Id = id, - FirstName = "John", - LastName = "Doe", - Email = "john.doe@somewhere.com", - Phone = "555-987-1234", - } ) - .Produces() - .Produces( 404 ); - -p3.MapPost( "/", ( HttpRequest request, ApiVersion version, PersonV3 person ) => - { - person.Id = 42; - var scheme = request.Scheme; - var host = request.Host; - var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/v{version}/api/people/{person.Id}" ); - return Results.Created( location, person ); - } ) - .Accepts( "application/json" ) - .Produces( 201 ) - .Produces( 400 ); +var peopleV3 = people.MapGroup( "/api/v{version:apiVersion}/people" ) + .HasApiVersion( 3.0 ); + +peopleV3.MapGet( "/", () => + new PersonV3[] + { + new() + { + Id = 1, + FirstName = "John", + LastName = "Doe", + Email = "john.doe@somewhere.com", + Phone = "555-987-1234", + }, + new() + { + Id = 2, + FirstName = "Bob", + LastName = "Smith", + Email = "bob.smith@somewhere.com", + Phone = "555-654-4321", + }, + new() + { + Id = 3, + FirstName = "Jane", + LastName = "Doe", + Email = "jane.doe@somewhere.com", + Phone = "555-789-3456", + }, + } ) + .Produces>(); + +peopleV3.MapGet( "/{id:int}", ( int id ) => + new PersonV3() + { + Id = id, + FirstName = "John", + LastName = "Doe", + Email = "john.doe@somewhere.com", + Phone = "555-987-1234", + } ) + .Produces() + .Produces( 404 ); + +peopleV3.MapPost( "/", ( HttpRequest request, ApiVersion version, PersonV3 person ) => + { + person.Id = 42; + var scheme = request.Scheme; + var host = request.Host; + var location = new Uri( $"{scheme}{Uri.SchemeDelimiter}{host}/v{version}/api/people/{person.Id}" ); + return Results.Created( location, person ); + } ) + .Accepts( "application/json" ) + .Produces( 201 ) + .Produces( 400 ); app.UseSwagger(); app.UseSwaggerUI( From 2e8d692830a0b97f341eed929c66bdb396b57b17 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Wed, 16 Nov 2022 12:43:00 -0800 Subject: [PATCH 5/6] Minor code clean up --- .../Builder/EndpointBuilderFinalizer.cs | 8 +-- .../IEndpointConventionBuilderExtensions.cs | 52 +++++++++---------- .../Builder/VersionedEndpointRouteBuilder.cs | 38 +++++++++----- 3 files changed, 54 insertions(+), 44 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs index 027dabf9..e3617913 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/EndpointBuilderFinalizer.cs @@ -32,7 +32,7 @@ private static void Finialize( EndpointBuilder endpointBuilder, ApiVersionSet? v { if ( versionSet is null ) { - // this should be impossible because WithApiVersionSet had to be called to get here + // this could only happen if the ApiVersionSet was removed elsewhere from the metadata endpointBuilder.Metadata.Add( ApiVersionMetadata.Empty ); return; } @@ -56,7 +56,7 @@ private static void Finialize( EndpointBuilder endpointBuilder, ApiVersionSet? v endpointBuilder.RequestDelegate = requestDelegate; } - var parameterSource = endpointBuilder.ApplicationServices.GetRequiredService(); + var parameterSource = services.GetRequiredService(); if ( parameterSource.VersionsByMediaType() ) { @@ -122,10 +122,10 @@ private static bool ReportApiVersions( IList metadata ) { for ( var i = metadata.Count - 1; i >= 0; i-- ) { - if ( metadata[i] is ApiVersionSet set ) + if ( metadata[i] is ApiVersionSet versionSet ) { metadata.RemoveAt( i ); - return set; + return versionSet; } } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs index df8ea86f..205a7765 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/IEndpointConventionBuilderExtensions.cs @@ -32,7 +32,7 @@ public static TBuilder WithApiVersionSet( throw new ArgumentNullException( nameof( apiVersionSet ) ); } - builder.Add( endpoint => AddWithValidation( endpoint, apiVersionSet ) ); + builder.Add( endpoint => AddMetadata( endpoint, apiVersionSet ) ); builder.Finally( EndpointBuilderFinalizer.FinalizeEndpoints ); return builder; @@ -393,7 +393,7 @@ public static TBuilder ReportApiVersions( this TBuilder builder ) return builder; } - private static void AddWithValidation( EndpointBuilder builder, ApiVersionSet versionSet ) + private static void AddMetadata( EndpointBuilder builder, ApiVersionSet versionSet ) { var metadata = builder.Metadata; var grouped = builder.ApplicationServices.GetService( typeof( ApiVersionSetBuilder ) ) is not null; @@ -414,30 +414,6 @@ private static void AddWithValidation( EndpointBuilder builder, ApiVersionSet ve metadata.Add( versionSet ); } - private static void AdvertiseInApiVersionSet( IList metadata, ApiVersion apiVersion ) - { - for ( var i = metadata.Count - 1; i >= 0; i-- ) - { - if ( metadata[i] is ApiVersionSet versionSet ) - { - versionSet.AdvertisesApiVersion( apiVersion ); - break; - } - } - } - - private static void AdvertiseDeprecatedInApiVersionSet( IList metadata, ApiVersion apiVersion ) - { - for ( var i = metadata.Count - 1; i >= 0; i-- ) - { - if ( metadata[i] is ApiVersionSet versionSet ) - { - versionSet.AdvertisesDeprecatedApiVersion( apiVersion ); - break; - } - } - } - private static void AddMetadata( EndpointBuilder builder, object item ) { var metadata = builder.Metadata; @@ -467,6 +443,30 @@ private static void AddMetadata( EndpointBuilder builder, object item ) nameof( IEndpointRouteBuilderExtensions.WithApiVersionSet ) ) ); } + private static void AdvertiseInApiVersionSet( IList metadata, ApiVersion apiVersion ) + { + for ( var i = metadata.Count - 1; i >= 0; i-- ) + { + if ( metadata[i] is ApiVersionSet versionSet ) + { + versionSet.AdvertisesApiVersion( apiVersion ); + break; + } + } + } + + private static void AdvertiseDeprecatedInApiVersionSet( IList metadata, ApiVersion apiVersion ) + { + for ( var i = metadata.Count - 1; i >= 0; i-- ) + { + if ( metadata[i] is ApiVersionSet versionSet ) + { + versionSet.AdvertisesDeprecatedApiVersion( apiVersion ); + break; + } + } + } + private sealed class SingleItemReadOnlyList : IReadOnlyList { private readonly ApiVersion item; diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointRouteBuilder.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointRouteBuilder.cs index 12fe750e..c1773a6c 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointRouteBuilder.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Builder/VersionedEndpointRouteBuilder.cs @@ -46,7 +46,8 @@ public VersionedEndpointRouteBuilder( protected ApiVersionSetBuilder VersionSetBuilder { get; } /// - public virtual IApplicationBuilder CreateApplicationBuilder() => routeBuilder.CreateApplicationBuilder(); + public virtual IApplicationBuilder CreateApplicationBuilder() => + routeBuilder.CreateApplicationBuilder(); /// public virtual IServiceProvider ServiceProvider => serviceProvider; @@ -55,7 +56,8 @@ public VersionedEndpointRouteBuilder( public virtual ICollection DataSources => dataSources; /// - public virtual void Add( Action convention ) => conventionBuilder.Add( convention ); + public virtual void Add( Action convention ) => + conventionBuilder.Add( convention ); private sealed class ServiceProviderDecorator : IServiceProvider { @@ -63,7 +65,9 @@ private sealed class ServiceProviderDecorator : IServiceProvider private readonly ApiVersionSetBuilder versionSetBuilder; private ApiVersionSet? versionSet; - internal ServiceProviderDecorator( IServiceProvider decorated, ApiVersionSetBuilder versionSetBuilder ) + internal ServiceProviderDecorator( + IServiceProvider decorated, + ApiVersionSetBuilder versionSetBuilder ) { this.decorated = decorated; this.versionSetBuilder = versionSetBuilder; @@ -71,14 +75,14 @@ internal ServiceProviderDecorator( IServiceProvider decorated, ApiVersionSetBuil public object? GetService( Type serviceType ) { - if ( typeof( ApiVersionSet ).Equals( serviceType ) ) + if ( typeof( ApiVersionSetBuilder ).Equals( serviceType ) ) { - return versionSet ??= versionSetBuilder.Build(); + return versionSetBuilder; } - if ( typeof( ApiVersionSetBuilder ).Equals( serviceType ) ) + if ( typeof( ApiVersionSet ).Equals( serviceType ) ) { - return versionSetBuilder; + return versionSet ??= versionSetBuilder.Build(); } return decorated.GetService( serviceType ); @@ -90,7 +94,9 @@ private sealed class EndpointDataSourceDecorator : EndpointDataSource private readonly EndpointDataSource decorated; private readonly ApiVersionSetBuilder versionSetBuilder; - internal EndpointDataSourceDecorator( EndpointDataSource decorated, ApiVersionSetBuilder versionSetBuilder ) + internal EndpointDataSourceDecorator( + EndpointDataSource decorated, + ApiVersionSetBuilder versionSetBuilder ) { this.decorated = decorated; this.versionSetBuilder = versionSetBuilder; @@ -104,14 +110,16 @@ public override IReadOnlyList GetGroupedEndpoints( RouteGroupContext c { CollateGroupApiVersions(); - // HACK: we don't have a way to pass the version set for the group down to each convention so - // decorate the service provider to allow it to be resolved. this requires rebuilding the - // current context as well. + // HACK: we don't have a way to pass the version set for the group down + // to each convention so decorate the service provider to allow it to + // be resolved. this requires rebuilding the current context as well. if ( context.ApplicationServices is not ServiceProviderDecorator ) { context = new() { - ApplicationServices = new ServiceProviderDecorator( context.ApplicationServices, versionSetBuilder ), + ApplicationServices = new ServiceProviderDecorator( + context.ApplicationServices, + versionSetBuilder ), Conventions = context.Conventions, FinallyConventions = context.FinallyConventions, Prefix = context.Prefix, @@ -121,7 +129,8 @@ public override IReadOnlyList GetGroupedEndpoints( RouteGroupContext c return decorated.GetGroupedEndpoints( context ); } - public override bool Equals( object? obj ) => ReferenceEquals( this, obj ) || ReferenceEquals( decorated, obj ); + public override bool Equals( object? obj ) => + ReferenceEquals( this, obj ) || ReferenceEquals( decorated, obj ); public override int GetHashCode() => decorated.GetHashCode(); @@ -190,7 +199,8 @@ public void Add( EndpointDataSource item ) => public bool Contains( EndpointDataSource item ) => adapted.Contains( item ); - public void CopyTo( EndpointDataSource[] array, int arrayIndex ) => adapted.CopyTo( array, arrayIndex ); + public void CopyTo( EndpointDataSource[] array, int arrayIndex ) => + adapted.CopyTo( array, arrayIndex ); public IEnumerator GetEnumerator() => adapted.GetEnumerator(); From 36abb5d9204d6d8614b0a00b504d57c483d3b3d1 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Wed, 16 Nov 2022 16:51:27 -0800 Subject: [PATCH 6/6] Use 404 vs 400 when versioning only by URL and no requested versions. Fixes #911 --- .../Routing/ApiVersionPolicyJumpTable.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs index 950c6cd8..ed8db5bd 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionPolicyJumpTable.cs @@ -11,6 +11,7 @@ namespace Asp.Versioning.Routing; internal sealed class ApiVersionPolicyJumpTable : PolicyJumpTable { private readonly bool versionsByUrl; + private readonly bool versionsByUrlOnly; private readonly bool versionsByMediaTypeOnly; private readonly RouteDestination rejection; private readonly IReadOnlyDictionary destinations; @@ -32,6 +33,7 @@ internal ApiVersionPolicyJumpTable( this.parser = parser; this.options = options; versionsByUrl = routePatterns.Count > 0; + versionsByUrlOnly = source.VersionsByUrl( allowMultipleLocations: false ); versionsByMediaTypeOnly = source.VersionsByMediaType( allowMultipleLocations: false ); } @@ -61,15 +63,18 @@ public override int GetDestination( HttpContext httpContext ) return destination; } - // 2. short-circuit if a default version cannot be assumed - if ( !options.AssumeDefaultVersionWhenUnspecified ) + // 2. IApiVersionSelector cannot be used yet because there are no candidates that an + // aggregated version model can be computed from to select the 'default' API version + if ( options.AssumeDefaultVersionWhenUnspecified ) { - return rejection.Unspecified; // 400 + return rejection.AssumeDefault; } - // 3. IApiVersionSelector cannot be used yet because there are no candidates that an - // aggregated version model can be computed from to select the 'default' API version - return rejection.AssumeDefault; + // 3. unspecified + return versionsByUrlOnly + /* 404 */ ? rejection.Exit + /* 400 */ : rejection.Unspecified; + case 1: rawApiVersion = apiVersions[0];