Skip to content

Commit 99e8596

Browse files
authored
[StaticAssets] Read Order from endpoint manifest in StaticAssetEndpointFactory (#65975)
Add string? Order property to StaticAssetDescriptor with freeze support and JsonIgnore(WhenWritingNull). StaticAssetEndpointFactory parses it to int at point of use (consistent with how Quality is handled on selectors), falling back to -100 when absent. This enables SPA fallback endpoints (Order=int.MaxValue) and default document endpoints to have configurable routing priority. Includes two integration tests: - EndpointOrder_DefaultsToNegative100_WhenOrderNotSet - EndpointOrder_UsesValueFromManifest_WhenOrderIsSet
1 parent f8ccfef commit 99e8596

4 files changed

Lines changed: 157 additions & 1 deletion

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
#nullable enable
2+
Microsoft.AspNetCore.StaticAssets.StaticAssetDescriptor.Order.get -> string?
3+
Microsoft.AspNetCore.StaticAssets.StaticAssetDescriptor.Order.set -> void

src/StaticAssets/src/StaticAssetDescriptor.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,21 @@ public sealed class StaticAssetDescriptor
1515
bool _isFrozen;
1616
private string? _route;
1717
private string? _assetFile;
18+
private string? _order;
1819
private IReadOnlyList<StaticAssetSelector> _selectors = [];
1920
private IReadOnlyList<StaticAssetProperty> _endpointProperties = [];
2021
private IReadOnlyList<StaticAssetResponseHeader> _responseHeaders = [];
2122

23+
/// <summary>
24+
/// The order of the endpoint in the routing table. When null or empty, the default order of -100 is used.
25+
/// </summary>
26+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
27+
public string? Order
28+
{
29+
get => _order;
30+
set => _order = !_isFrozen ? value : throw new InvalidOperationException("StaticAssetDescriptor is frozen and doesn't accept further changes");
31+
}
32+
2233
/// <summary>
2334
/// The route that the asset is served from.
2435
/// </summary>

src/StaticAssets/src/StaticAssetEndpointFactory.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ public Endpoint Create(StaticAssetDescriptor resource, List<Action<EndpointBuild
2424
// Static resources always take precedence over default routes to mimic the behavior of UseStaticFiles.
2525
// We give a -100 order to ensure that they are selected under normal circumstances, but leave a small lee-way
2626
// for the user to override this if they want to.
27-
-100);
27+
// If the endpoint has an explicit order (e.g., SPA fallback endpoints), use that instead.
28+
!string.IsNullOrEmpty(resource.Order) && int.TryParse(resource.Order, CultureInfo.InvariantCulture, out var order) ? order : -100);
2829

2930
foreach (var selector in resource.Selectors)
3031
{

src/StaticAssets/test/StaticAssetsIntegrationTests.cs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using System.Text;
1111
using System.Text.Json;
1212
using Microsoft.AspNetCore.Builder;
13+
using Microsoft.AspNetCore.Routing;
1314
using Microsoft.AspNetCore.TestHost;
1415
using Microsoft.Extensions.DependencyInjection;
1516
using Microsoft.Extensions.FileProviders;
@@ -1222,6 +1223,147 @@ public Stream CreateReadStream()
12221223
}
12231224
}
12241225

1226+
[Fact]
1227+
public async Task EndpointOrder_DefaultsToNegative100_WhenOrderNotSet()
1228+
{
1229+
// Arrange
1230+
var appName = nameof(EndpointOrder_DefaultsToNegative100_WhenOrderNotSet);
1231+
var (contentRoot, webRoot) = ConfigureAppPaths(appName);
1232+
1233+
CreateTestManifest(
1234+
appName,
1235+
webRoot,
1236+
[
1237+
new TestResource("sample.txt", "Hello, World!", false),
1238+
]);
1239+
1240+
var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
1241+
{
1242+
ApplicationName = appName,
1243+
ContentRootPath = contentRoot,
1244+
EnvironmentName = "Development",
1245+
WebRootPath = webRoot
1246+
});
1247+
builder.WebHost.ConfigureServices(services =>
1248+
{
1249+
services.AddRouting();
1250+
});
1251+
builder.WebHost.UseTestServer();
1252+
1253+
var app = builder.Build();
1254+
app.UseRouting();
1255+
app.UseEndpoints(endpoints =>
1256+
{
1257+
endpoints.MapStaticAssets();
1258+
});
1259+
1260+
await app.StartAsync();
1261+
1262+
// Act
1263+
var endpoint = app.Services.GetRequiredService<EndpointDataSource>().Endpoints
1264+
.OfType<RouteEndpoint>()
1265+
.Single(e => e.RoutePattern.RawText == "sample.txt");
1266+
1267+
// Assert
1268+
Assert.Equal(-100, endpoint.Order);
1269+
1270+
Directory.Delete(webRoot, true);
1271+
}
1272+
1273+
[Fact]
1274+
public async Task EndpointOrder_UsesValueFromManifest_WhenOrderIsSet()
1275+
{
1276+
// Arrange
1277+
var appName = nameof(EndpointOrder_UsesValueFromManifest_WhenOrderIsSet);
1278+
var (contentRoot, webRoot) = ConfigureAppPaths(appName);
1279+
1280+
Directory.CreateDirectory(webRoot);
1281+
var manifestPath = Path.Combine(AppContext.BaseDirectory, $"{appName}.staticwebassets.endpoints.json");
1282+
var hash = GetEtag("Hello, World!");
1283+
var lastModified = DateTimeOffset.UtcNow;
1284+
File.WriteAllText(Path.Combine(webRoot, "index.html"), "Hello, World!");
1285+
1286+
var manifest = new StaticAssetsManifest()
1287+
{
1288+
Version = 1,
1289+
ManifestType = "Build",
1290+
Endpoints =
1291+
[
1292+
new StaticAssetDescriptor
1293+
{
1294+
Route = "index.html",
1295+
AssetPath = "index.html",
1296+
Selectors = [],
1297+
Properties = [new("integrity", $"sha256-{hash}")],
1298+
ResponseHeaders = [
1299+
new("Accept-Ranges", "bytes"),
1300+
new("Content-Length", "Hello, World!".Length.ToString(CultureInfo.InvariantCulture)),
1301+
new("Content-Type", "text/html"),
1302+
new("ETag", $"\"{hash}\""),
1303+
new("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture)),
1304+
],
1305+
Order = "2147483647"
1306+
},
1307+
new StaticAssetDescriptor
1308+
{
1309+
Route = "other.html",
1310+
AssetPath = "index.html",
1311+
Selectors = [],
1312+
Properties = [new("integrity", $"sha256-{hash}")],
1313+
ResponseHeaders = [
1314+
new("Accept-Ranges", "bytes"),
1315+
new("Content-Length", "Hello, World!".Length.ToString(CultureInfo.InvariantCulture)),
1316+
new("Content-Type", "text/html"),
1317+
new("ETag", $"\"{hash}\""),
1318+
new("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture)),
1319+
],
1320+
}
1321+
]
1322+
};
1323+
1324+
{
1325+
using var stream = File.Create(manifestPath);
1326+
using var writer = new Utf8JsonWriter(stream);
1327+
JsonSerializer.Serialize(writer, manifest);
1328+
}
1329+
1330+
var appBuilder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
1331+
{
1332+
ApplicationName = appName,
1333+
ContentRootPath = contentRoot,
1334+
EnvironmentName = "Development",
1335+
WebRootPath = webRoot
1336+
});
1337+
appBuilder.WebHost.ConfigureServices(services =>
1338+
{
1339+
services.AddRouting();
1340+
});
1341+
appBuilder.WebHost.UseTestServer();
1342+
1343+
var app = appBuilder.Build();
1344+
app.UseRouting();
1345+
app.UseEndpoints(endpoints =>
1346+
{
1347+
endpoints.MapStaticAssets();
1348+
});
1349+
1350+
await app.StartAsync();
1351+
1352+
// Act
1353+
var endpoints = app.Services.GetRequiredService<EndpointDataSource>().Endpoints
1354+
.OfType<RouteEndpoint>()
1355+
.ToList();
1356+
1357+
var indexEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "index.html");
1358+
var otherEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "other.html");
1359+
1360+
// Assert
1361+
Assert.Equal(2147483647, indexEndpoint.Order);
1362+
Assert.Equal(-100, otherEndpoint.Order);
1363+
1364+
Directory.Delete(webRoot, true);
1365+
}
1366+
12251367
[Fact]
12261368
public void TruncateToSeconds_RemovesSubsecondComponents()
12271369
{

0 commit comments

Comments
 (0)