Skip to content
Open
Show file tree
Hide file tree
Changes from all 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,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;

/// <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);

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<ResourceAssetCollection>().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!);
}
}
}
}
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,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");
Copy link
Member

Choose a reason for hiding this comment

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

@copilot Add some tests that use MapGet too

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added 2 new MapGet tests in commit a563dfa:

  1. WithStaticAssets_OnMapGet_AddsResourceCollection_WhenEndpointBuilderImplementsIEndpointRouteBuilder: Tests calling WithStaticAssets() directly on a MapGet() result
  2. WithStaticAssets_OnMapGetInGroup_AddsResourceCollection: Tests MapGet() inside a group that has WithStaticAssets() applied

All 6 tests pass.


group.WithStaticAssets();

var endpointBuilder = new TestEndpointBuilder(routeBuilder);
ApplyConventions(group, endpointBuilder);

var metadata = endpointBuilder.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 group = routeBuilder.MapGroup("/test");

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

var endpointBuilder = new TestEndpointBuilder(routeBuilder);
ApplyConventions(group, endpointBuilder);

var collection = endpointBuilder.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 = endpointBuilder.Metadata.OfType<ResourcePreloadCollection>().FirstOrDefault();
Assert.NotNull(preloadCollection);

var importMap = endpointBuilder.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 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<ResourceAssetCollection>().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<ResourceAssetCollection>().FirstOrDefault();
Assert.NotNull(collection);

var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(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<ResourceAssetCollection>().FirstOrDefault();
Assert.NotNull(collection);

var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(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<ResourceAssetCollection>().FirstOrDefault();
Assert.NotNull(collection);

var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(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<Action<EndpointBuilder>>)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<Action<EndpointBuilder>>)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<EndpointDataSource> 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<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