diff --git a/src/Components/Endpoints/src/Builder/EndpointConventionBuilderResourceCollectionExtensions.cs b/src/Components/Endpoints/src/Builder/EndpointConventionBuilderResourceCollectionExtensions.cs new file mode 100644 index 000000000000..5e215bac7a58 --- /dev/null +++ b/src/Components/Endpoints/src/Builder/EndpointConventionBuilderResourceCollectionExtensions.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Endpoints; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Extensions for to add resource collection metadata. +/// +public static class EndpointConventionBuilderResourceCollectionExtensions +{ + /// + /// Provides a helper to attach ResourceCollection metadata to endpoints. + /// + /// The . + /// The manifest associated with the assets. + /// The that can be used to further configure the endpoints. + /// + /// This method attaches static asset metadata to endpoints. It provides a simplified way to add + /// resource collection metadata to any endpoint convention builder. + /// The must match the path of the manifest file provided to + /// the call. + /// + public static IEndpointConventionBuilder WithStaticAssets( + this IEndpointConventionBuilder builder, + string? manifestPath = null) + { + ArgumentNullException.ThrowIfNull(builder); + + ResourceCollectionConvention convention = new ResourceCollectionConvention(manifestPath); + + builder.Add(convention.Apply); + + return builder; + } + + private sealed class ResourceCollectionConvention + { + private readonly string? _manifestPath; + private ResourceAssetCollection? _collection; + private ResourcePreloadCollection? _preloadCollection; + private ImportMapDefinition? _importMap; + + public ResourceCollectionConvention(string? manifestPath) + { + _manifestPath = manifestPath; + } + + public void Apply(EndpointBuilder endpointBuilder) + { + // Check if there's already a resource collection on the metadata + if (endpointBuilder.Metadata.OfType().Any() || + endpointBuilder is not IEndpointRouteBuilder routeEndpointBuilder) + { + return; + } + + if (_collection == null) + { + // We only use the resolver to get to the datasources so we can cache the results for + // all endpoints in the collection + var resolver = new ResourceCollectionResolver(routeEndpointBuilder); + + if (resolver.IsRegistered(_manifestPath)) + { + _collection = resolver.ResolveResourceCollection(_manifestPath); + _preloadCollection = new ResourcePreloadCollection(_collection); + _importMap = ImportMapDefinition.FromResourceCollection(_collection); + } + } + + if (_collection != null) + { + endpointBuilder.Metadata.Add(_collection); + endpointBuilder.Metadata.Add(_preloadCollection!); + endpointBuilder.Metadata.Add(_importMap!); + } + } + } +} \ No newline at end of file diff --git a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt index ac1780ba883e..0f575d8b05a9 100644 --- a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt +++ b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt @@ -1,3 +1,5 @@ #nullable enable +Microsoft.AspNetCore.Builder.EndpointConventionBuilderResourceCollectionExtensions +static Microsoft.AspNetCore.Builder.EndpointConventionBuilderResourceCollectionExtensions.WithStaticAssets(this Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! builder, string? manifestPath = null) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! Microsoft.AspNetCore.Components.Endpoints.BasePath Microsoft.AspNetCore.Components.Endpoints.BasePath.BasePath() -> void diff --git a/src/Components/Endpoints/test/Builder/EndpointConventionBuilderResourceCollectionExtensionsTest.cs b/src/Components/Endpoints/test/Builder/EndpointConventionBuilderResourceCollectionExtensionsTest.cs new file mode 100644 index 000000000000..75ea374002aa --- /dev/null +++ b/src/Components/Endpoints/test/Builder/EndpointConventionBuilderResourceCollectionExtensionsTest.cs @@ -0,0 +1,234 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Endpoints; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; + +namespace Microsoft.AspNetCore.Components.Endpoints.Tests.Builder; + +public class EndpointConventionBuilderResourceCollectionExtensionsTest +{ + [Fact] + public void WithStaticAssets_DoesNotAddResourceCollection_ToEndpoints_NoStaticAssetsMapped() + { + var routeBuilder = new TestEndpointRouteBuilder(); + var group = routeBuilder.MapGroup("/test"); + + group.WithStaticAssets(); + + var endpointBuilder = new TestEndpointBuilder(routeBuilder); + ApplyConventions(group, endpointBuilder); + + var metadata = endpointBuilder.Metadata.OfType().FirstOrDefault(); + Assert.Null(metadata); + } + + [Fact] + public void WithStaticAssets_AddsResourceCollection_ToEndpoints_WithMatchingManifest() + { + var routeBuilder = new TestEndpointRouteBuilder(); + routeBuilder.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json"); + var group = routeBuilder.MapGroup("/test"); + + group.WithStaticAssets("TestManifests/Test.staticwebassets.endpoints.json"); + + var endpointBuilder = new TestEndpointBuilder(routeBuilder); + ApplyConventions(group, endpointBuilder); + + var collection = endpointBuilder.Metadata.OfType().FirstOrDefault(); + Assert.NotNull(collection); + + var list = Assert.IsAssignableFrom>(collection); + Assert.Single(list); + Assert.Equal("named.css", list[0].Url); + + var preloadCollection = endpointBuilder.Metadata.OfType().FirstOrDefault(); + Assert.NotNull(preloadCollection); + + var importMap = endpointBuilder.Metadata.OfType().FirstOrDefault(); + Assert.NotNull(importMap); + } + + [Fact] + public void WithStaticAssets_DoesNotAddResourceCollection_WhenAlreadyExists() + { + var routeBuilder = new TestEndpointRouteBuilder(); + routeBuilder.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json"); + var group = routeBuilder.MapGroup("/test"); + + var existingCollection = new ResourceAssetCollection([]); + var endpointBuilder = new TestEndpointBuilder(routeBuilder); + endpointBuilder.Metadata.Add(existingCollection); + + group.WithStaticAssets("TestManifests/Test.staticwebassets.endpoints.json"); + ApplyConventions(group, endpointBuilder); + + var collections = endpointBuilder.Metadata.OfType().ToList(); + Assert.Single(collections); + Assert.Same(existingCollection, collections[0]); + } + + [Fact] + public void WithStaticAssets_AddsResourceCollection_ToEndpoints_DefaultManifest() + { + var routeBuilder = new TestEndpointRouteBuilder(); + routeBuilder.MapStaticAssets(); + var group = routeBuilder.MapGroup("/test"); + + group.WithStaticAssets(); + + var endpointBuilder = new TestEndpointBuilder(routeBuilder); + ApplyConventions(group, endpointBuilder); + + var collection = endpointBuilder.Metadata.OfType().FirstOrDefault(); + Assert.NotNull(collection); + + var list = Assert.IsAssignableFrom>(collection); + Assert.Single(list); + Assert.Equal("default.css", list[0].Url); + } + + [Fact] + public void WithStaticAssets_OnMapGet_AddsResourceCollection_WhenEndpointBuilderImplementsIEndpointRouteBuilder() + { + var routeBuilder = new TestEndpointRouteBuilder(); + routeBuilder.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json"); + + var mapGet = routeBuilder.MapGet("/endpoint", () => "test"); + mapGet.WithStaticAssets("TestManifests/Test.staticwebassets.endpoints.json"); + + // When conventions are applied to an endpoint builder that implements IEndpointRouteBuilder, + // the metadata is added even if the convention was added via MapGet + var endpointBuilder = new TestEndpointBuilder(routeBuilder); + ApplyConventions(mapGet, endpointBuilder); + + var collection = endpointBuilder.Metadata.OfType().FirstOrDefault(); + Assert.NotNull(collection); + + var list = Assert.IsAssignableFrom>(collection); + Assert.Single(list); + Assert.Equal("named.css", list[0].Url); + } + + [Fact] + public void WithStaticAssets_OnMapGetInGroup_AddsResourceCollection() + { + var routeBuilder = new TestEndpointRouteBuilder(); + routeBuilder.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json"); + + var group = routeBuilder.MapGroup("/test"); + group.WithStaticAssets("TestManifests/Test.staticwebassets.endpoints.json"); + + var mapGet = group.MapGet("/endpoint", () => "test"); + + var endpointBuilder = new TestEndpointBuilder(routeBuilder); + ApplyConventions(group, endpointBuilder); + + // When MapGet is inside a group that has WithStaticAssets, the metadata is added + var collection = endpointBuilder.Metadata.OfType().FirstOrDefault(); + Assert.NotNull(collection); + + var list = Assert.IsAssignableFrom>(collection); + Assert.Single(list); + Assert.Equal("named.css", list[0].Url); + } + + private static void ApplyConventions(RouteGroupBuilder group, EndpointBuilder endpointBuilder) + { + // Access conventions via reflection since they're private + var conventionsField = typeof(RouteGroupBuilder).GetField("_conventions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var conventions = (List>)conventionsField!.GetValue(group)!; + + foreach (var convention in conventions) + { + convention(endpointBuilder); + } + } + + private static void ApplyConventions(RouteHandlerBuilder handler, EndpointBuilder endpointBuilder) + { + // Access conventions via reflection since they're private + var conventionsField = typeof(RouteHandlerBuilder).GetField("_conventions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var conventions = (List>)conventionsField!.GetValue(handler)!; + + foreach (var convention in conventions) + { + convention(endpointBuilder); + } + } + + // Test builder that implements both EndpointBuilder and IEndpointRouteBuilder + private class TestEndpointBuilder : EndpointBuilder, IEndpointRouteBuilder + { + private readonly IEndpointRouteBuilder _routeBuilder; + + public TestEndpointBuilder(IEndpointRouteBuilder routeBuilder) + { + _routeBuilder = routeBuilder; + ApplicationServices = TestEndpointRouteBuilder.CreateServiceProvider(); + } + + public override Endpoint Build() + { + throw new NotImplementedException(); + } + + public IServiceProvider ServiceProvider => _routeBuilder.ServiceProvider; + public ICollection DataSources => _routeBuilder.DataSources; + public IApplicationBuilder CreateApplicationBuilder() => _routeBuilder.CreateApplicationBuilder(); + } + + private class TestEndpointRouteBuilder : IEndpointRouteBuilder + { + private readonly ApplicationBuilder _applicationBuilder; + + public TestEndpointRouteBuilder() + { + _applicationBuilder = new ApplicationBuilder(ServiceProvider); + } + + public IServiceProvider ServiceProvider { get; } = CreateServiceProvider(); + + public static IServiceProvider CreateServiceProvider() + { + var collection = new ServiceCollection(); + collection.AddSingleton(new ConfigurationBuilder().Build()); + collection.AddSingleton(new TestWebHostEnvironment()); + collection.AddRazorComponents(); + return collection.BuildServiceProvider(); + } + + public ICollection DataSources { get; } = []; + + public IApplicationBuilder CreateApplicationBuilder() + { + return _applicationBuilder.New(); + } + + private class TestWebHostEnvironment : IWebHostEnvironment + { + public string ApplicationName { get; set; } = "TestApplication"; + public string EnvironmentName { get; set; } = "TestEnvironment"; + public string WebRootPath { get; set; } = ""; + public IFileProvider WebRootFileProvider { get => ContentRootFileProvider; set { } } + public string ContentRootPath { get; set; } = Directory.GetCurrentDirectory(); + public IFileProvider ContentRootFileProvider { get; set; } = CreateTestFileProvider(); + + private static TestFileProvider CreateTestFileProvider() + { + var provider = new TestFileProvider(); + provider.AddFile("site.css", "body { color: red; }"); + return provider; + } + } + } +} \ No newline at end of file