Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/StaticAssets/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
Microsoft.AspNetCore.StaticAssets.StaticAssetDescriptor.Order.get -> string?
Microsoft.AspNetCore.StaticAssets.StaticAssetDescriptor.Order.set -> void
11 changes: 11 additions & 0 deletions src/StaticAssets/src/StaticAssetDescriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,21 @@ public sealed class StaticAssetDescriptor
bool _isFrozen;
private string? _route;
private string? _assetFile;
private string? _order;
private IReadOnlyList<StaticAssetSelector> _selectors = [];
private IReadOnlyList<StaticAssetProperty> _endpointProperties = [];
private IReadOnlyList<StaticAssetResponseHeader> _responseHeaders = [];

/// <summary>
/// The order of the endpoint in the routing table. When null or empty, the default order of -100 is used.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Order
{
get => _order;
set => _order = !_isFrozen ? value : throw new InvalidOperationException("StaticAssetDescriptor is frozen and doesn't accept further changes");
}
Comment on lines +23 to +31
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

Since this is a public API and the property type is string?, the XML docs should clarify the expected format/range (e.g., invariant-culture integer that must fit in int) and what happens when an invalid value is provided. Right now it's easy for consumers to set a non-numeric value without realizing it will be ignored/defaulted at endpoint creation time.

Copilot uses AI. Check for mistakes.

/// <summary>
/// The route that the asset is served from.
/// </summary>
Expand Down
3 changes: 2 additions & 1 deletion src/StaticAssets/src/StaticAssetEndpointFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ public Endpoint Create(StaticAssetDescriptor resource, List<Action<EndpointBuild
// Static resources always take precedence over default routes to mimic the behavior of UseStaticFiles.
// We give a -100 order to ensure that they are selected under normal circumstances, but leave a small lee-way
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

Typo in comment: "lee-way" should be "leeway".

Suggested change
// We give a -100 order to ensure that they are selected under normal circumstances, but leave a small lee-way
// We give a -100 order to ensure that they are selected under normal circumstances, but leave a small leeway

Copilot uses AI. Check for mistakes.
// for the user to override this if they want to.
-100);
// If the endpoint has an explicit order (e.g., SPA fallback endpoints), use that instead.
!string.IsNullOrEmpty(resource.Order) && int.TryParse(resource.Order, CultureInfo.InvariantCulture, out var order) ? order : -100);
Comment on lines +27 to +28
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

When resource.Order is present but not a valid int, this silently falls back to -100, which can mask a bad/corrupt manifest and produce unexpected routing precedence. Consider treating invalid non-empty values as an error (e.g., throw with route/manifest context, or at least log) and only default to -100 when the value is null/empty.

Copilot uses AI. Check for mistakes.

foreach (var selector in resource.Selectors)
{
Expand Down
142 changes: 142 additions & 0 deletions src/StaticAssets/test/StaticAssetsIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
Expand Down Expand Up @@ -1222,6 +1223,147 @@ public Stream CreateReadStream()
}
}

[Fact]
public async Task EndpointOrder_DefaultsToNegative100_WhenOrderNotSet()
{
// Arrange
var appName = nameof(EndpointOrder_DefaultsToNegative100_WhenOrderNotSet);
var (contentRoot, webRoot) = ConfigureAppPaths(appName);

CreateTestManifest(
appName,
webRoot,
[
new TestResource("sample.txt", "Hello, World!", false),
]);

var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
{
ApplicationName = appName,
ContentRootPath = contentRoot,
EnvironmentName = "Development",
WebRootPath = webRoot
});
builder.WebHost.ConfigureServices(services =>
{
services.AddRouting();
});
builder.WebHost.UseTestServer();

var app = builder.Build();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapStaticAssets();
});

await app.StartAsync();

// Act
var endpoint = app.Services.GetRequiredService<EndpointDataSource>().Endpoints
.OfType<RouteEndpoint>()
.Single(e => e.RoutePattern.RawText == "sample.txt");

// Assert
Assert.Equal(-100, endpoint.Order);

Directory.Delete(webRoot, true);
}

[Fact]
public async Task EndpointOrder_UsesValueFromManifest_WhenOrderIsSet()
{
// Arrange
var appName = nameof(EndpointOrder_UsesValueFromManifest_WhenOrderIsSet);
var (contentRoot, webRoot) = ConfigureAppPaths(appName);

Directory.CreateDirectory(webRoot);
var manifestPath = Path.Combine(AppContext.BaseDirectory, $"{appName}.staticwebassets.endpoints.json");
var hash = GetEtag("Hello, World!");
var lastModified = DateTimeOffset.UtcNow;
File.WriteAllText(Path.Combine(webRoot, "index.html"), "Hello, World!");

var manifest = new StaticAssetsManifest()
{
Version = 1,
ManifestType = "Build",
Endpoints =
[
new StaticAssetDescriptor
{
Route = "index.html",
AssetPath = "index.html",
Selectors = [],
Properties = [new("integrity", $"sha256-{hash}")],
ResponseHeaders = [
new("Accept-Ranges", "bytes"),
new("Content-Length", "Hello, World!".Length.ToString(CultureInfo.InvariantCulture)),
new("Content-Type", "text/html"),
new("ETag", $"\"{hash}\""),
new("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture)),
],
Order = "2147483647"
},
new StaticAssetDescriptor
{
Route = "other.html",
AssetPath = "index.html",
Selectors = [],
Properties = [new("integrity", $"sha256-{hash}")],
ResponseHeaders = [
new("Accept-Ranges", "bytes"),
new("Content-Length", "Hello, World!".Length.ToString(CultureInfo.InvariantCulture)),
new("Content-Type", "text/html"),
new("ETag", $"\"{hash}\""),
new("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture)),
],
}
]
};

{
using var stream = File.Create(manifestPath);
using var writer = new Utf8JsonWriter(stream);
JsonSerializer.Serialize(writer, manifest);
}

var appBuilder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
{
ApplicationName = appName,
ContentRootPath = contentRoot,
EnvironmentName = "Development",
WebRootPath = webRoot
});
appBuilder.WebHost.ConfigureServices(services =>
{
services.AddRouting();
});
appBuilder.WebHost.UseTestServer();

var app = appBuilder.Build();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapStaticAssets();
});

await app.StartAsync();

// Act
var endpoints = app.Services.GetRequiredService<EndpointDataSource>().Endpoints
.OfType<RouteEndpoint>()
.ToList();

var indexEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "index.html");
var otherEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "other.html");

// Assert
Assert.Equal(2147483647, indexEndpoint.Order);
Assert.Equal(-100, otherEndpoint.Order);

Directory.Delete(webRoot, true);
}

[Fact]
public void TruncateToSeconds_RemovesSubsecondComponents()
{
Expand Down
Loading