Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// 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;

/// <summary>
/// Extensions for <see cref="IEndpointConventionBuilder"/> to add resource collection metadata.
/// </summary>
public static class EndpointConventionBuilderResourceCollectionExtensions
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file is named EndpointRouteBuilderResourceCollectionExtensions.cs but contains a class named EndpointConventionBuilderResourceCollectionExtensions. The file name should match the class name. Consider renaming the file to EndpointConventionBuilderResourceCollectionExtensions.cs to follow C# conventions where file names match the primary type they contain.

Suggested change
public static class EndpointConventionBuilderResourceCollectionExtensions
public static class EndpointRouteBuilderResourceCollectionExtensions

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 37575d5. Renamed file from EndpointRouteBuilderResourceCollectionExtensions.cs to EndpointConventionBuilderResourceCollectionExtensions.cs to match the class name.

{
/// <summary>
/// Provides a helper to attach ResourceCollection metadata to endpoints.
/// </summary>
/// <param name="builder">The <see cref="IEndpointConventionBuilder"/>.</param>
/// <param name="manifestPath">The manifest associated with the assets.</param>
/// <returns>The <see cref="IEndpointConventionBuilder"/> that can be used to further configure the endpoints.</returns>
/// <remarks>
/// This method attaches static asset metadata to endpoints. It provides a simplified way to add
/// resource collection metadata to any endpoint convention builder.
/// The <paramref name="manifestPath"/> must match the path of the manifest file provided to
/// the <see cref="StaticAssetsEndpointRouteBuilderExtensions.MapStaticAssets(IEndpointRouteBuilder, string?)"/> call.
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The word "manifes" should be "manifest" - there's a missing 't' at the end.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 37575d5. Corrected typo from "manifes" to "manifest" in XML documentation.

/// </remarks>
public static IEndpointConventionBuilder WithStaticAssets(
this IEndpointConventionBuilder builder,
string? manifestPath = null)
{
ArgumentNullException.ThrowIfNull(builder);

// Check if builder also implements IEndpointRouteBuilder (like RouteGroupBuilder does)
if (builder is IEndpointRouteBuilder routeBuilder)
{
var convention = new ResourceCollectionConvention(routeBuilder, manifestPath);
builder.Add(convention.Apply);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. This is wrong. You need to go back to the old model where we called builder.Add IEndpointConventionBuilder and IEndpointBuilder are not related. IEndpointRouteBuilder extends IEndpointBuilder. Iendpointconventionbuilder.Add gives you access to the IEndpointBuilder that you can then be downcast to IEndpointRouteBuilder to conditionally attach the resources to the collection.

        ResourceAssetCollection? collection = null;
        ResourcePreloadCollection? preloadCollection = null;
        ImportMapDefinition? importMap = null;

Put these things outside of the conventionBuilder.Add callback or create a private nested sealed class

ResourceConvention
{
        ResourceAssetCollection? _collection = null;
        ResourcePreloadCollection? _preloadCollection = null;
        ImportMapDefinition? _importMap = null;

       public void Apply(IEndpointBuilder builder)
       {
            if(builder is not IEndpointRouteBuilder routeBuilder)
            {
                return;
            }

            _collection ??= ...
            
       }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 3b229a1. Now correctly checking if builder (the IEndpointConventionBuilder) implements IEndpointRouteBuilder, rather than checking endpointBuilder. Uses a private nested ResourceCollectionConvention class that captures the route builder and lazily initializes collections on the first endpoint for optimal performance.


return builder;
}

private sealed class ResourceCollectionConvention
{
private readonly IEndpointRouteBuilder _routeBuilder;
private readonly string? _manifestPath;
private ResourceAssetCollection? _collection;
private ResourcePreloadCollection? _preloadCollection;
private ImportMapDefinition? _importMap;
private bool _initialized;

public ResourceCollectionConvention(IEndpointRouteBuilder routeBuilder, string? manifestPath)
{
_routeBuilder = routeBuilder;
_manifestPath = manifestPath;
}

public void Apply(EndpointBuilder endpointBuilder)
{
// Check if there's already a resource collection on the metadata
if (endpointBuilder.Metadata.OfType<ResourceAssetCollection>().Any())
{
return;
}

// Lazy initialization: resolve collection once on first endpoint
if (!_initialized)
{
_initialized = true;
var resolver = new ResourceCollectionResolver(_routeBuilder);

if (resolver.IsRegistered(_manifestPath))
{
_collection = resolver.ResolveResourceCollection(_manifestPath);
_preloadCollection = new ResourcePreloadCollection(_collection);
_importMap = ImportMapDefinition.FromResourceCollection(_collection);
}
}

// If collection was resolved, add it to the endpoint
if (_collection != null)
{
endpointBuilder.Metadata.Add(_collection);
endpointBuilder.Metadata.Add(_preloadCollection!);
endpointBuilder.Metadata.Add(_importMap!);
}
}
}
}
2 changes: 2 additions & 0 deletions src/Components/Endpoints/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// 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 conventionBuilder = new TestRouteGroupBuilder(routeBuilder);

conventionBuilder.WithStaticAssets();

var endpointBuilderInstance = new TestEndpointBuilder();
conventionBuilder.ApplyConventions(endpointBuilderInstance);

var metadata = endpointBuilderInstance.Metadata.OfType<ResourceAssetCollection>().FirstOrDefault();
Assert.Null(metadata);
}

[Fact]
public void WithStaticAssets_AddsResourceCollection_ToEndpoints_WithMatchingManifest()
{
var routeBuilder = new TestEndpointRouteBuilder();
routeBuilder.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
var conventionBuilder = new TestRouteGroupBuilder(routeBuilder);

conventionBuilder.WithStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");

var endpointBuilderInstance = new TestEndpointBuilder();
conventionBuilder.ApplyConventions(endpointBuilderInstance);

var collection = endpointBuilderInstance.Metadata.OfType<ResourceAssetCollection>().FirstOrDefault();
Assert.NotNull(collection);

var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(collection);
Assert.Single(list);
Assert.Equal("named.css", list[0].Url);

var preloadCollection = endpointBuilderInstance.Metadata.OfType<ResourcePreloadCollection>().FirstOrDefault();
Assert.NotNull(preloadCollection);

var importMap = endpointBuilderInstance.Metadata.OfType<ImportMapDefinition>().FirstOrDefault();
Assert.NotNull(importMap);
}

[Fact]
public void WithStaticAssets_DoesNotAddResourceCollection_WhenAlreadyExists()
{
var routeBuilder = new TestEndpointRouteBuilder();
routeBuilder.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
var conventionBuilder = new TestRouteGroupBuilder(routeBuilder);

var existingCollection = new ResourceAssetCollection([]);
var endpointBuilderInstance = new TestEndpointBuilder();
endpointBuilderInstance.Metadata.Add(existingCollection);

conventionBuilder.WithStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
conventionBuilder.ApplyConventions(endpointBuilderInstance);

var collections = endpointBuilderInstance.Metadata.OfType<ResourceAssetCollection>().ToList();
Assert.Single(collections);
Assert.Same(existingCollection, collections[0]);
}

[Fact]
public void WithStaticAssets_AddsResourceCollection_ToEndpoints_DefaultManifest()
{
var routeBuilder = new TestEndpointRouteBuilder();
routeBuilder.MapStaticAssets();
var conventionBuilder = new TestRouteGroupBuilder(routeBuilder);

conventionBuilder.WithStaticAssets();

var endpointBuilderInstance = new TestEndpointBuilder();
conventionBuilder.ApplyConventions(endpointBuilderInstance);

var collection = endpointBuilderInstance.Metadata.OfType<ResourceAssetCollection>().FirstOrDefault();
Assert.NotNull(collection);

var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(collection);
Assert.Single(list);
Assert.Equal("default.css", list[0].Url);
}

// Test builder that implements both IEndpointConventionBuilder and IEndpointRouteBuilder
private class TestRouteGroupBuilder : IEndpointConventionBuilder, IEndpointRouteBuilder
{
private readonly TestEndpointRouteBuilder _routeBuilder;
private readonly List<Action<EndpointBuilder>> _conventions = [];

public TestRouteGroupBuilder(TestEndpointRouteBuilder routeBuilder)
{
_routeBuilder = routeBuilder;
}

public void Add(Action<EndpointBuilder> convention)
{
ArgumentNullException.ThrowIfNull(convention);
_conventions.Add(convention);
}

public void ApplyConventions(EndpointBuilder endpointBuilder)
{
foreach (var convention in _conventions)
{
convention(endpointBuilder);
}
}

public IServiceProvider ServiceProvider => _routeBuilder.ServiceProvider;
public ICollection<EndpointDataSource> DataSources => _routeBuilder.DataSources;
public IApplicationBuilder CreateApplicationBuilder() => _routeBuilder.CreateApplicationBuilder();
}

private class TestEndpointBuilder : EndpointBuilder
{
public TestEndpointBuilder()
{
ApplicationServices = TestEndpointRouteBuilder.CreateServiceProvider();
}

public override Endpoint Build()
{
throw new NotImplementedException();
}
}

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<IConfiguration>(new ConfigurationBuilder().Build());
collection.AddSingleton<IWebHostEnvironment>(new TestWebHostEnvironment());
collection.AddRazorComponents();
return collection.BuildServiceProvider();
}

public ICollection<EndpointDataSource> 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;
}
}
}
}
Loading