diff --git a/samples/YarpApplication.SampleApps/01-marketing-site/appsettings.json b/samples/YarpApplication.SampleApps/01-marketing-site/appsettings.json new file mode 100644 index 000000000..71fa05e36 --- /dev/null +++ b/samples/YarpApplication.SampleApps/01-marketing-site/appsettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "../../../src/Application/yarp-config.schema.json", + "StaticFiles": { + "Enabled": true + }, + "NavigationFallback": { + "Path": "/index.html" + }, + "Headers": [ + { + "Match": { "Path": "/{**catch-all}" }, + "Set": { + "X-Content-Type-Options": "nosniff" + } + }, + { + "Match": { "Path": "/assets/{**catch-all}" }, + "Set": { + "Cache-Control": "public,max-age=31536000,immutable", + "X-Site": "contoso-marketing" + } + } + ] +} + diff --git a/samples/YarpApplication.SampleApps/01-marketing-site/wwwroot/assets/site.css b/samples/YarpApplication.SampleApps/01-marketing-site/wwwroot/assets/site.css new file mode 100644 index 000000000..7f918ee5a --- /dev/null +++ b/samples/YarpApplication.SampleApps/01-marketing-site/wwwroot/assets/site.css @@ -0,0 +1,12 @@ +body { + color: #17324d; + font-family: system-ui, sans-serif; + margin: 4rem; +} + +.eyebrow { + color: #476f95; + letter-spacing: .08em; + text-transform: uppercase; +} + diff --git a/samples/YarpApplication.SampleApps/01-marketing-site/wwwroot/index.html b/samples/YarpApplication.SampleApps/01-marketing-site/wwwroot/index.html new file mode 100644 index 000000000..04579ea3a --- /dev/null +++ b/samples/YarpApplication.SampleApps/01-marketing-site/wwwroot/index.html @@ -0,0 +1,16 @@ + + + + + Contoso Launch + + + +
+

Contoso Cloud

+

Launch campaigns without shipping a custom server.

+

Static assets are served directly, campaign deep links fall back to this shell, and asset responses get cache headers from config.

+
+ + + diff --git a/samples/YarpApplication.SampleApps/02-docs-site/appsettings.json b/samples/YarpApplication.SampleApps/02-docs-site/appsettings.json new file mode 100644 index 000000000..d323e18dd --- /dev/null +++ b/samples/YarpApplication.SampleApps/02-docs-site/appsettings.json @@ -0,0 +1,28 @@ +{ + "$schema": "../../../src/Application/yarp-config.schema.json", + "StaticFiles": { + "Enabled": true + }, + "Redirects": [ + { + "Match": { "Path": "/learn/{slug}" }, + "Destination": "/docs/{slug}.html", + "StatusCode": 301 + } + ], + "Rewrites": [ + { + "Regex": "^docs/current/(.*)$", + "Replacement": "docs/v2/$1" + } + ], + "Headers": [ + { + "Match": { "Path": "/docs/{**catch-all}" }, + "Set": { + "X-Docs-Site": "contoso-docs" + } + } + ] +} + diff --git a/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/docs/intro.html b/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/docs/intro.html new file mode 100644 index 000000000..5c3ae9f1f --- /dev/null +++ b/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/docs/intro.html @@ -0,0 +1,8 @@ + + + +

Intro docs

+

This is the canonical documentation URL after redirecting old /learn links.

+ + + diff --git a/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/docs/v2/intro.html b/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/docs/v2/intro.html new file mode 100644 index 000000000..7c41a2407 --- /dev/null +++ b/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/docs/v2/intro.html @@ -0,0 +1,8 @@ + + + +

Current intro docs

+

The /docs/current path is rewritten to this versioned file.

+ + + diff --git a/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/index.html b/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/index.html new file mode 100644 index 000000000..b3015d706 --- /dev/null +++ b/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/index.html @@ -0,0 +1,8 @@ + + + +

Contoso Docs

+

Use /learn/intro for a redirected legacy link, or /docs/current/intro.html for a rewritten versioned link.

+ + + diff --git a/samples/YarpApplication.SampleApps/03-dashboard-spa/appsettings.json b/samples/YarpApplication.SampleApps/03-dashboard-spa/appsettings.json new file mode 100644 index 000000000..2adc518fa --- /dev/null +++ b/samples/YarpApplication.SampleApps/03-dashboard-spa/appsettings.json @@ -0,0 +1,42 @@ +{ + "$schema": "../../../src/Application/yarp-config.schema.json", + "StaticFiles": { + "Enabled": true + }, + "NavigationFallback": { + "Path": "/index.html", + "Exclude": [ + { "Path": "/api/{**catch-all}" } + ] + }, + "Headers": [ + { + "Match": { "Path": "/{**catch-all}" }, + "Set": { + "X-Static-Host": "dashboard" + } + }, + { + "Match": { "Path": "/assets/{**catch-all}" }, + "Set": { + "Cache-Control": "public,max-age=3600" + } + } + ], + "ReverseProxy": { + "Routes": { + "api": { + "ClusterId": "catalog-api", + "Match": { "Path": "/api/{**catch-all}" } + } + }, + "Clusters": { + "catalog-api": { + "Destinations": { + "d1": { "Address": "http://catalog-api:8080/" } + } + } + } + } +} + diff --git a/samples/YarpApplication.SampleApps/03-dashboard-spa/wwwroot/assets/site.css b/samples/YarpApplication.SampleApps/03-dashboard-spa/wwwroot/assets/site.css new file mode 100644 index 000000000..dfa6274a7 --- /dev/null +++ b/samples/YarpApplication.SampleApps/03-dashboard-spa/wwwroot/assets/site.css @@ -0,0 +1,7 @@ +body { + background: #101827; + color: #f7fafc; + font-family: system-ui, sans-serif; + margin: 3rem; +} + diff --git a/samples/YarpApplication.SampleApps/03-dashboard-spa/wwwroot/index.html b/samples/YarpApplication.SampleApps/03-dashboard-spa/wwwroot/index.html new file mode 100644 index 000000000..c9141cf15 --- /dev/null +++ b/samples/YarpApplication.SampleApps/03-dashboard-spa/wwwroot/index.html @@ -0,0 +1,13 @@ + + + + + Operations Dashboard + + + +

Operations Dashboard

+

Client-side dashboard routes fall back here. Requests under /api are excluded from fallback and forwarded to the catalog API.

+ + + diff --git a/samples/YarpApplication.SampleApps/04-commerce-errors/appsettings.json b/samples/YarpApplication.SampleApps/04-commerce-errors/appsettings.json new file mode 100644 index 000000000..a65895a16 --- /dev/null +++ b/samples/YarpApplication.SampleApps/04-commerce-errors/appsettings.json @@ -0,0 +1,33 @@ +{ + "$schema": "../../../src/Application/yarp-config.schema.json", + "StaticFiles": { + "Enabled": true + }, + "NavigationFallback": { + "Path": "/index.html", + "Exclude": [ + { "Path": "/checkout/{**catch-all}" } + ] + }, + "ErrorPages": { + "404": "/errors/not-found.html", + "503": "/errors/maintenance.html", + "5xx": "/errors/server-error.html" + }, + "ReverseProxy": { + "Routes": { + "checkout": { + "ClusterId": "checkout", + "Match": { "Path": "/checkout/{**catch-all}" } + } + }, + "Clusters": { + "checkout": { + "Destinations": { + "d1": { "Address": "http://checkout-api:8080/" } + } + } + } + } +} + diff --git a/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/maintenance.html b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/maintenance.html new file mode 100644 index 000000000..9c0b6a31f --- /dev/null +++ b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/maintenance.html @@ -0,0 +1,8 @@ + + + +

Checkout is temporarily unavailable

+

This exact 503 page wins over the broader 5xx wildcard page.

+ + + diff --git a/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/not-found.html b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/not-found.html new file mode 100644 index 000000000..045f8edc2 --- /dev/null +++ b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/not-found.html @@ -0,0 +1,8 @@ + + + +

We could not find that product

+

Try browsing featured categories or search again.

+ + + diff --git a/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/server-error.html b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/server-error.html new file mode 100644 index 000000000..a045f54d9 --- /dev/null +++ b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/server-error.html @@ -0,0 +1,8 @@ + + + +

Something went wrong

+

The store is still responding with the original 5xx status code.

+ + + diff --git a/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/index.html b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/index.html new file mode 100644 index 000000000..d3646a424 --- /dev/null +++ b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/index.html @@ -0,0 +1,8 @@ + + + +

Contoso Store

+

The storefront is static, checkout is proxied, and failures render branded error pages while preserving the original status code.

+ + + diff --git a/samples/YarpApplication.SampleApps/05-edge-composition/appsettings.json b/samples/YarpApplication.SampleApps/05-edge-composition/appsettings.json new file mode 100644 index 000000000..1f52c0cf4 --- /dev/null +++ b/samples/YarpApplication.SampleApps/05-edge-composition/appsettings.json @@ -0,0 +1,73 @@ +{ + "$schema": "../../../src/Application/yarp-config.schema.json", + "StaticFiles": { + "Enabled": true + }, + "NavigationFallback": { + "Path": "/index.html", + "Exclude": [ + { "Path": "/api/{**catch-all}" }, + { "Path": "/orders/{**catch-all}" } + ] + }, + "Headers": [ + { + "Match": { "Path": "/{**catch-all}" }, + "Set": { + "X-Static-Host": "edge-frontend" + } + }, + { + "Match": { "Path": "/assets/{**catch-all}" }, + "Set": { + "Cache-Control": "public,max-age=31536000,immutable" + } + } + ], + "Redirects": [ + { + "Match": { "Path": "/old-campaign/{slug}" }, + "Destination": "/campaigns/{slug}", + "StatusCode": 301 + } + ], + "Rewrites": [ + { + "Regex": "^legacy-static$", + "Replacement": "assets/app.css" + }, + { + "Regex": "^content/(.*)$", + "Replacement": "api/content/$1" + } + ], + "ErrorPages": { + "404": "/errors/not-found.html", + "5xx": "/errors/server-error.html" + }, + "ReverseProxy": { + "Routes": { + "content": { + "ClusterId": "content", + "Match": { "Path": "/api/content/{**catch-all}" } + }, + "orders": { + "ClusterId": "orders", + "Match": { "Path": "/orders/{**catch-all}" } + } + }, + "Clusters": { + "content": { + "Destinations": { + "d1": { "Address": "http://content-api:8080/" } + } + }, + "orders": { + "Destinations": { + "d1": { "Address": "http://orders-api:8080/" } + } + } + } + } +} + diff --git a/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/assets/app.css b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/assets/app.css new file mode 100644 index 000000000..e489d88d8 --- /dev/null +++ b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/assets/app.css @@ -0,0 +1,6 @@ +body { + color: #222; + font-family: system-ui, sans-serif; + margin: 3rem; +} + diff --git a/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/errors/not-found.html b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/errors/not-found.html new file mode 100644 index 000000000..831bdbaca --- /dev/null +++ b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/errors/not-found.html @@ -0,0 +1,8 @@ + + + +

Page not found

+

The edge frontend served this branded 404 page.

+ + + diff --git a/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/errors/server-error.html b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/errors/server-error.html new file mode 100644 index 000000000..c6c419cb8 --- /dev/null +++ b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/errors/server-error.html @@ -0,0 +1,8 @@ + + + +

Service temporarily unavailable

+

The original 5xx status code is preserved.

+ + + diff --git a/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/index.html b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/index.html new file mode 100644 index 000000000..2d578697e --- /dev/null +++ b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/index.html @@ -0,0 +1,13 @@ + + + + + Contoso Edge Frontend + + + +

Contoso Edge Frontend

+

This sample combines static hosting, route exclusions, redirects, rewrites, proxying, and custom error pages.

+ + + diff --git a/samples/YarpApplication.SampleApps/README.md b/samples/YarpApplication.SampleApps/README.md new file mode 100644 index 000000000..b44cbcf5d --- /dev/null +++ b/samples/YarpApplication.SampleApps/README.md @@ -0,0 +1,25 @@ +# YARP Application sample apps + +These samples are small, realistic app layouts for the YARP application static-hosting and routing-rule features. They are not .NET projects; each sample is just an `appsettings.json` file plus any static `wwwroot` assets it needs. + +Run any sample from the repository root: + +```bash +export DOTNET_ROOT="$PWD/.dotnet" +export DOTNET_MULTILEVEL_LOOKUP=0 +export PATH="$PWD/.dotnet:$PATH" + +ASPNETCORE_URLS=http://127.0.0.1:5000 \ + dotnet run --no-launch-profile --project src/Application/Yarp.Application.csproj -- \ + samples/YarpApplication.SampleApps/01-marketing-site/appsettings.json +``` + +Then open `http://127.0.0.1:5000`. Samples that include `ReverseProxy` use realistic placeholder backend addresses such as `http://catalog-api:8080/`; point those at your own local services when trying them. + +| Sample app | Demonstrates | +| --- | --- | +| `01-marketing-site` | A static marketing site with SPA-style campaign fallback and long-lived asset caching headers. | +| `02-docs-site` | A documentation site with old URL redirects, "current docs" rewrites, and docs-specific headers. | +| `03-dashboard-spa` | A dashboard SPA that falls back to `index.html` while forwarding `/api` traffic to a backend. | +| `04-commerce-errors` | A commerce frontend with branded exact and wildcard custom error pages. | +| `05-edge-composition` | A composed edge frontend using rewrites, redirects, static assets, proxy routes, SPA fallback exclusions, and custom error pages together. | diff --git a/src/Application/Configuration/HeaderRule.cs b/src/Application/Configuration/HeaderRule.cs new file mode 100644 index 000000000..59ce0d31a --- /dev/null +++ b/src/Application/Configuration/HeaderRule.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Yarp.Application.Configuration; + +public sealed class HeaderRule +{ + public RequestMatch Match { get; set; } = new(); + + public Dictionary Set { get; set; } = []; +} diff --git a/src/Application/Configuration/NavigationFallbackOptions.cs b/src/Application/Configuration/NavigationFallbackOptions.cs index fb724e118..8707dd651 100644 --- a/src/Application/Configuration/NavigationFallbackOptions.cs +++ b/src/Application/Configuration/NavigationFallbackOptions.cs @@ -6,4 +6,6 @@ namespace Yarp.Application.Configuration; public sealed class NavigationFallbackOptions { public string? Path { get; set; } + + public List Exclude { get; set; } = []; } diff --git a/src/Application/Configuration/RedirectRule.cs b/src/Application/Configuration/RedirectRule.cs new file mode 100644 index 000000000..3072de2ef --- /dev/null +++ b/src/Application/Configuration/RedirectRule.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Yarp.Application.Configuration; + +public sealed class RedirectRule +{ + public RequestMatch Match { get; set; } = new(); + + public string? Destination { get; set; } + + public int StatusCode { get; set; } = 301; +} diff --git a/src/Application/Configuration/RequestMatch.cs b/src/Application/Configuration/RequestMatch.cs new file mode 100644 index 000000000..4f25a8e62 --- /dev/null +++ b/src/Application/Configuration/RequestMatch.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Yarp.Application.Configuration; + +public sealed class RequestMatch +{ + public string? Path { get; set; } + + public List Hosts { get; set; } = []; + + public List Methods { get; set; } = []; + + public List QueryParameters { get; set; } = []; +} diff --git a/src/Application/Configuration/RewriteRule.cs b/src/Application/Configuration/RewriteRule.cs new file mode 100644 index 000000000..9b99f08a3 --- /dev/null +++ b/src/Application/Configuration/RewriteRule.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Yarp.Application.Configuration; + +/// +/// URL rewrite rule. Maps directly to +/// . +/// +public sealed class RewriteRule +{ + /// The regular expression to match against the request path. + public string? Regex { get; set; } + + /// The replacement string. May reference regex capture groups via $1, $2, etc. + public string? Replacement { get; set; } + + /// If true (default), skip remaining rules when this rule matches. + public bool SkipRemainingRules { get; set; } = true; +} diff --git a/src/Application/Configuration/YarpAppConfig.cs b/src/Application/Configuration/YarpAppConfig.cs index c55e0439f..c386aa995 100644 --- a/src/Application/Configuration/YarpAppConfig.cs +++ b/src/Application/Configuration/YarpAppConfig.cs @@ -11,5 +11,9 @@ public sealed class YarpAppConfig { public StaticFilesOptions StaticFiles { get; set; } = new(); public NavigationFallbackOptions NavigationFallback { get; set; } = new(); + public List Headers { get; set; } = []; + public List Redirects { get; set; } = []; + public List Rewrites { get; set; } = []; + public Dictionary ErrorPages { get; set; } = new(StringComparer.OrdinalIgnoreCase); public TelemetryOptions Telemetry { get; set; } = new(); } diff --git a/src/Application/Configuration/YarpAppConfigBinder.cs b/src/Application/Configuration/YarpAppConfigBinder.cs index 5ecffec4e..ce3385a70 100644 --- a/src/Application/Configuration/YarpAppConfigBinder.cs +++ b/src/Application/Configuration/YarpAppConfigBinder.cs @@ -17,6 +17,10 @@ public static YarpAppConfig Bind(IConfiguration configuration) configuration.GetSection(nameof(config.StaticFiles)).Bind(config.StaticFiles); configuration.GetSection(nameof(config.NavigationFallback)).Bind(config.NavigationFallback); + configuration.GetSection(nameof(config.Headers)).Bind(config.Headers); + configuration.GetSection(nameof(config.Redirects)).Bind(config.Redirects); + configuration.GetSection(nameof(config.Rewrites)).Bind(config.Rewrites); + configuration.GetSection(nameof(config.ErrorPages)).Bind(config.ErrorPages); configuration.GetSection(nameof(config.Telemetry)).Bind(config.Telemetry); // Legacy env var support @@ -42,9 +46,9 @@ private static void MapLegacyKeys(IConfiguration configuration, YarpAppConfig co && !string.Equals(configuration["YARP_DISABLE_SPA_FALLBACK"], "true", StringComparison.OrdinalIgnoreCase)) { // Legacy behavior: SPA fallback was on by default when static files were enabled - // Only apply if using legacy keys (no explicit NavigationFallback section) - if (!configuration.GetSection(nameof(config.NavigationFallback)).Exists() - && string.Equals(configuration["YARP_ENABLE_STATIC_FILES"], "true", StringComparison.OrdinalIgnoreCase)) + // through YARP_ENABLE_STATIC_FILES. Adding new NavigationFallback children like Exclude + // should not disable that default unless Path itself was explicitly configured. + if (string.Equals(configuration["YARP_ENABLE_STATIC_FILES"], "true", StringComparison.OrdinalIgnoreCase)) { config.NavigationFallback.Path = "/index.html"; } diff --git a/src/Application/Extensions.cs b/src/Application/Extensions.cs index cbaca4a97..debb96a9d 100644 --- a/src/Application/Extensions.cs +++ b/src/Application/Extensions.cs @@ -4,7 +4,10 @@ using HealthChecks.ApplicationStatus.DependencyInjection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using OpenTelemetry; using OpenTelemetry.Exporter; @@ -12,6 +15,7 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Trace; using Yarp.Application.Configuration; +using Yarp.Application.Features; namespace Microsoft.Extensions.Hosting; @@ -24,6 +28,7 @@ public static TBuilder AddServiceDefaults(this TBuilder builder, YarpA builder.AddDefaultHealthChecks(); builder.Services.AddServiceDiscovery(); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); return builder; } diff --git a/src/Application/Features/ErrorPagesFeature.cs b/src/Application/Features/ErrorPagesFeature.cs new file mode 100644 index 000000000..5732f3fec --- /dev/null +++ b/src/Application/Features/ErrorPagesFeature.cs @@ -0,0 +1,239 @@ +// 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.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing; +using Yarp.Application.Configuration; + +namespace Yarp.Application.Features; + +public static class ErrorPagesFeature +{ + /// + /// Re-executes the request against a configured page when an empty 4xx/5xx response is + /// produced downstream. The original status code is preserved (the browser still sees + /// 404, 500, etc.). Keys are 3-digit status codes ("404") or class wildcards + /// ("4xx", "5xx"); exact codes win over wildcards. + /// + public static WebApplication UseErrorPages(this WebApplication app, YarpAppConfig config) + { + if (config.ErrorPages.Count == 0) + { + return app; + } + + var rules = ErrorPageRules.Compile(config.ErrorPages); + + app.UseStatusCodePages(async context => + { + var http = context.HttpContext; + var path = rules.Resolve(http.Response.StatusCode); + if (path is null) + { + return; + } + + // StatusCodePages runs after the downstream pipeline has produced an error response. + // Mirror UseStatusCodePagesWithReExecute, but choose the destination from the status + // code map instead of using one template for every status. + var originalPath = http.Request.Path; + var originalQueryString = http.Request.QueryString; + var originalStatusCode = http.Response.StatusCode; + var originalEndpoint = http.GetEndpoint(); + var routeValuesFeature = http.Features.Get(); + var originalRouteValues = routeValuesFeature?.RouteValues is { } routeValues + ? new RouteValueDictionary(routeValues) + : null; + + // StatusCodeReExecuteFeature has an internal/private setter for OriginalStatusCode + // in some target frameworks. Use our own feature so custom error endpoints can still + // inspect the original status if they need it. + http.Features.Set(new ErrorPageReExecuteFeature + { + OriginalPathBase = http.Request.PathBase.Value!, + OriginalPath = originalPath.Value!, + OriginalQueryString = originalQueryString.HasValue ? originalQueryString.Value : null, + OriginalStatusCode = originalStatusCode, + Endpoint = originalEndpoint, + RouteValues = originalRouteValues, + }); + + // Routing has already selected an endpoint for the original request. Clear it so the + // re-executed path can be matched as a new request against redirects/static/proxy/ + // fallback endpoints. + http.SetEndpoint(null); + http.Features.Get()?.RouteValues?.Clear(); + + // The response currently contains the original error status (for example 404). Clear + // it before re-executing so the target can run normally. This is especially important + // for YARP proxy targets because the forwarder refuses to start when the response has + // already been set to a non-200 status. + http.Response.Clear(); + + // Error page targets typically produce a 200 response (static files, proxy backends, + // etc.). Restore the original status immediately before headers are sent so the client + // sees the original 404/500 while receiving the custom page body. + http.Response.OnStarting(static state => + { + var (response, statusCode) = ((HttpResponse Response, int StatusCode))state; + response.StatusCode = statusCode; + return Task.CompletedTask; + }, (http.Response, originalStatusCode)); + + http.Request.Path = path; + http.Request.QueryString = QueryString.Empty; + try + { + await context.Next(http); + } + finally + { + // If the target did not start the response, OnStarting will not run. Preserve the + // same status-code guarantee for empty/not-started responses. + if (!http.Response.HasStarted) + { + http.Response.StatusCode = originalStatusCode; + } + + // Restore request/routing state for anything later in the pipeline and for logging + // or diagnostics that observe the context after the re-execute completes. + http.Request.QueryString = originalQueryString; + http.Request.Path = originalPath; + http.SetEndpoint(originalEndpoint); + if (routeValuesFeature is not null) + { + routeValuesFeature.RouteValues = originalRouteValues ?? new RouteValueDictionary(); + } + + http.Features.Set(null); + } + }); + + return app; + } + + private sealed class ErrorPageReExecuteFeature : IStatusCodeReExecuteFeature + { + public string OriginalPathBase { get; set; } = string.Empty; + public string OriginalPath { get; set; } = string.Empty; + public string? OriginalQueryString { get; set; } + public int OriginalStatusCode { get; init; } + public Endpoint? Endpoint { get; set; } + public RouteValueDictionary? RouteValues { get; set; } + } + + private sealed class ErrorPageRules + { + private readonly Dictionary _exact; + private readonly Dictionary _classes; + + private ErrorPageRules(Dictionary exact, Dictionary classes) + { + _exact = exact; + _classes = classes; + } + + public string? Resolve(int statusCode) + { + if (_exact.TryGetValue(statusCode, out var path)) + { + return path; + } + + if (_classes.TryGetValue(statusCode / 100, out path)) + { + return path; + } + + return null; + } + + public static ErrorPageRules Compile(IDictionary source) + { + var exact = new Dictionary(); + var classes = new Dictionary(); + + foreach (var (key, value) in source) + { + var path = ValidatePath(key, value); + + // Parse ErrorPages keys as either exact status codes ("404") or status classes + // ("5xx"). Exact matches are stored separately so "404" wins over "4xx". + if (TryParseExactCode(key, out var code)) + { + exact[code] = path; + } + else if (TryParseClassWildcard(key, out var hundreds)) + { + classes[hundreds] = path; + } + else + { + throw new InvalidOperationException( + $"ErrorPages key '{key}' must be a 3-digit status code (e.g. '404') or a class wildcard (e.g. '5xx')."); + } + } + + return new ErrorPageRules(exact, classes); + } + + private static string ValidatePath(string key, string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new InvalidOperationException( + $"ErrorPages entry '{key}' must have a non-empty path."); + } + + if (path[0] != '/') + { + throw new InvalidOperationException( + $"ErrorPages entry '{key}' path '{path}' must start with '/'."); + } + + return path; + } + + private static bool TryParseExactCode(string key, out int code) + { + code = 0; + // Parse exact status-code keys like "404" into the integer 404. + if (key.Length != 3) + { + return false; + } + + for (var i = 0; i < 3; i++) + { + if (!char.IsDigit(key[i])) + { + return false; + } + } + + code = int.Parse(key, System.Globalization.CultureInfo.InvariantCulture); + return true; + } + + private static bool TryParseClassWildcard(string key, out int hundreds) + { + hundreds = 0; + // Parse status-class wildcard keys like "5xx" or "5XX" into the hundreds digit 5. + if (key.Length != 3 || !char.IsDigit(key[0])) + { + return false; + } + + if ((key[1] != 'x' && key[1] != 'X') || (key[2] != 'x' && key[2] != 'X')) + { + return false; + } + + hundreds = key[0] - '0'; + return true; + } + } +} diff --git a/src/Application/Features/NavigationFallbackEndpointMetadata.cs b/src/Application/Features/NavigationFallbackEndpointMetadata.cs new file mode 100644 index 000000000..85eb0babc --- /dev/null +++ b/src/Application/Features/NavigationFallbackEndpointMetadata.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Yarp.Application.Features; + +// Marker metadata applied to the built-in SPA fallback endpoint. +// Static files do not become endpoints, so only fallback-specific middleware relies on this. +internal sealed class NavigationFallbackEndpointMetadata +{ + public static NavigationFallbackEndpointMetadata Instance { get; } = new(); + + private NavigationFallbackEndpointMetadata() + { + } +} diff --git a/src/Application/Features/NavigationFallbackExclusionsFeature.cs b/src/Application/Features/NavigationFallbackExclusionsFeature.cs new file mode 100644 index 000000000..dd6bd09ed --- /dev/null +++ b/src/Application/Features/NavigationFallbackExclusionsFeature.cs @@ -0,0 +1,48 @@ +// 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.Http; +using Microsoft.AspNetCore.Routing; +using Yarp.Application.Configuration; + +namespace Yarp.Application.Features; + +public static class NavigationFallbackExclusionsFeature +{ + // Endpoint routing chooses the lowest Order first. Exclusions are regular endpoints, not + // MapFallback endpoints, so patterns like "/.well-known/{**catch-all}" also match dotted + // file-like paths. Place them just before the SPA fallback while still letting normal + // endpoints (including proxy routes) win, and add the config index to preserve rule order. + private const int FallbackExclusionEndpointOrderBase = int.MaxValue - 1000; + + public static WebApplication MapNavigationFallbackExclusions(this WebApplication app, YarpAppConfig config) + { + if (config.NavigationFallback.Path is null || config.NavigationFallback.Exclude.Count == 0) + { + return app; + } + + for (var i = 0; i < config.NavigationFallback.Exclude.Count; i++) + { + var match = config.NavigationFallback.Exclude[i]; + var path = RequestMatchEvaluator.GetPathPattern(match, "NavigationFallback exclusion"); + var order = FallbackExclusionEndpointOrderBase + i; + app.Map( + path, + context => + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return Task.CompletedTask; + }) + .Add(endpointBuilder => + { + endpointBuilder.DisplayName = $"Fallback exclusion {path}"; + ((RouteEndpointBuilder)endpointBuilder).Order = order; + RequestMatchEvaluator.AddEndpointMetadata(endpointBuilder, match, "NavigationFallback exclusion"); + }); + } + + return app; + } +} diff --git a/src/Application/Features/NavigationFallbackFeature.cs b/src/Application/Features/NavigationFallbackFeature.cs index 9ba98a9ac..1df9a7e0d 100644 --- a/src/Application/Features/NavigationFallbackFeature.cs +++ b/src/Application/Features/NavigationFallbackFeature.cs @@ -12,7 +12,11 @@ public static WebApplication MapNavigationFallback(this WebApplication app, Yarp { if (config.NavigationFallback.Path is not null) { - app.MapFallbackToFile(config.NavigationFallback.Path); + var fallback = app.MapFallbackToFile(config.NavigationFallback.Path); + // Mark the SPA fallback endpoint so the fallback-specific middleware can distinguish + // it from reverse proxy endpoints, while static files continue to flow through + // StaticFileMiddleware without endpoint metadata. + fallback.Add(endpointBuilder => endpointBuilder.Metadata.Add(NavigationFallbackEndpointMetadata.Instance)); } return app; diff --git a/src/Application/Features/RedirectsFeature.cs b/src/Application/Features/RedirectsFeature.cs new file mode 100644 index 000000000..a3ae98c06 --- /dev/null +++ b/src/Application/Features/RedirectsFeature.cs @@ -0,0 +1,89 @@ +// 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.Http; +using Microsoft.AspNetCore.Routing; +using Yarp.Application.Configuration; + +namespace Yarp.Application.Features; + +public static class RedirectsFeature +{ + // Endpoint routing chooses the lowest Order first when multiple route patterns match. + // Put configured redirects in a negative range so "/old/{**path}" beats normal proxy/ + // fallback endpoints, and add the config index so earlier redirect rules win ties. + private const int RedirectEndpointOrderBase = -1000; + + public static WebApplication MapRedirects(this WebApplication app, YarpAppConfig config) + { + if (config.Redirects.Count == 0) + { + return app; + } + + var headerRules = ResponseHeadersFeature.CompileHeaderRules(config); + for (var i = 0; i < config.Redirects.Count; i++) + { + var rule = new CompiledRedirectRule(config.Redirects[i]); + var order = RedirectEndpointOrderBase + i; + app.Map( + rule.Path, + context => + { + context.Response.StatusCode = rule.StatusCode; + context.Response.Headers.Location = rule.BuildDestination(context.Request.RouteValues); + ResponseHeadersFeature.ApplyHeaders(context, context.Request.Path, context.Response.Headers, headerRules); + return Task.CompletedTask; + }) + // Redirects need to run ahead of static files, so execute them directly from + // endpoint routing instead of waiting for the normal endpoint middleware. + .ShortCircuit() + .Add(endpointBuilder => + { + endpointBuilder.DisplayName = $"Redirect {rule.Path}"; + ((RouteEndpointBuilder)endpointBuilder).Order = order; + RequestMatchEvaluator.AddEndpointMetadata(endpointBuilder, rule.Match, "Redirect rules"); + }); + } + + return app; + } + + private sealed class CompiledRedirectRule + { + private static readonly HashSet AllowedStatusCodes = [301, 302, 307, 308]; + + public CompiledRedirectRule(RedirectRule rule) + { + Match = rule.Match ?? throw new InvalidOperationException("Redirect rules requires a Match object."); + Path = RequestMatchEvaluator.GetPathPattern(Match, "Redirect rules"); + + if (string.IsNullOrWhiteSpace(rule.Destination)) + { + throw new InvalidOperationException( + $"Redirect rule '{Path}' requires a non-empty Destination."); + } + + if (!AllowedStatusCodes.Contains(rule.StatusCode)) + { + throw new InvalidOperationException( + $"Redirect rule '{Path}' has unsupported status code '{rule.StatusCode}'. Expected one of: 301, 302, 307, 308."); + } + + Destination = rule.Destination; + StatusCode = rule.StatusCode; + } + + public string Destination { get; } + + public int StatusCode { get; } + + public string Path { get; } + + public RequestMatch Match { get; } + + public string BuildDestination(RouteValueDictionary values) + => RequestMatchEvaluator.ExpandTemplate(Destination, values); + } +} diff --git a/src/Application/Features/RequestMatchEvaluator.cs b/src/Application/Features/RequestMatchEvaluator.cs new file mode 100644 index 000000000..88b77b074 --- /dev/null +++ b/src/Application/Features/RequestMatchEvaluator.cs @@ -0,0 +1,186 @@ +// 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.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Template; +using Microsoft.Extensions.Primitives; +using Yarp.Application.Configuration; +using Yarp.ReverseProxy.Configuration; + +namespace Yarp.Application.Features; + +internal sealed class RequestMatchEvaluator +{ + private const string CatchAllPathPattern = "/{**catch-all}"; + + private readonly TemplateMatcher _pathMatcher; + private readonly StringSegment[] _hosts; + private readonly string[] _methods; + private readonly RequestQueryParameterMatcher[] _queryMatchers; + + public RequestMatchEvaluator(RequestMatch match, string ruleDisplayName) + { + var path = GetPathPattern(match, ruleDisplayName); + Path = path; + // Parse ASP.NET route-template syntax such as "/api/{**catch-all}" or + // "/docs/{slug}" so static-host header rules use the same path semantics as endpoints. + _pathMatcher = new TemplateMatcher(TemplateParser.Parse(path), new RouteValueDictionary()); + _hosts = CompileHosts(match.Hosts, ruleDisplayName); + _methods = CompileMethods(match.Methods, ruleDisplayName); + _queryMatchers = CompileQueryParameters(match.QueryParameters, ruleDisplayName); + } + + public string Path { get; } + + /// + /// Returns the route pattern for . Use this when path matching is + /// delegated to ASP.NET endpoint routing and a full is + /// unnecessary. Empty paths follow YARP route-match behavior and become a catch-all pattern. + /// + public static string GetPathPattern(RequestMatch match, string ruleDisplayName) + { + if (match is null) + { + throw new InvalidOperationException($"{ruleDisplayName} requires a Match object."); + } + + if (string.IsNullOrWhiteSpace(match.Path)) + { + return CatchAllPathPattern; + } + + return match.Path; + } + + public static void AddEndpointMetadata(EndpointBuilder endpointBuilder, RequestMatch match, string ruleDisplayName) + { + if (match.Hosts.Count > 0) + { + endpointBuilder.Metadata.Add(new HostAttribute( + CompileHosts(match.Hosts, ruleDisplayName).Select(static host => host.Value!).ToArray())); + } + + if (match.Methods.Count > 0) + { + endpointBuilder.Metadata.Add(new HttpMethodMetadata(CompileMethods(match.Methods, ruleDisplayName))); + } + + if (match.QueryParameters.Count > 0) + { + endpointBuilder.Metadata.Add(new RequestQueryParameterMetadata( + CompileQueryParameters(match.QueryParameters, ruleDisplayName))); + } + } + + public bool TryMatch(HttpContext context, RouteValueDictionary values) + { + ArgumentNullException.ThrowIfNull(context); + return TryMatch(context, context.Request.Path, values); + } + + public bool TryMatch(HttpContext context, PathString path, RouteValueDictionary values) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(values); + + // Route matching expects a rooted request path. Normal requests already have one, but + // normalize the empty-path case so "/" behaves consistently in tests and callbacks. + path = string.IsNullOrEmpty(path.Value) ? new PathString("/") : path; + return _pathMatcher.TryMatch(path, values) + && MatchHost(context.Request.Host) + && MatchMethod(context.Request.Method) + && MatchQuery(context.Request.Query); + } + + /// + /// Substitutes {name} placeholders in with values from + /// . For example, "/docs/{slug}" with slug + /// "intro" becomes "/docs/intro". Missing or null values resolve to an + /// empty string. + /// + public static string ExpandTemplate(string template, RouteValueDictionary values) + { + if (values.Count == 0 || template.IndexOf('{') < 0) + { + return template; + } + + var builder = new System.Text.StringBuilder(template); + foreach (var value in values) + { + builder.Replace("{" + value.Key + "}", value.Value?.ToString() ?? string.Empty); + } + + return builder.ToString(); + } + + private static StringSegment[] CompileHosts(List hosts, string ruleDisplayName) + { + var compiled = new StringSegment[hosts.Count]; + for (var i = 0; i < hosts.Count; i++) + { + if (string.IsNullOrWhiteSpace(hosts[i])) + { + throw new InvalidOperationException($"{ruleDisplayName} contains an empty Match.Hosts entry."); + } + + // Parse host patterns using endpoint routing's HostString matcher format. Examples: + // "example.com", "*.example.com", and "example.com:8443". + compiled[i] = new StringSegment(hosts[i]); + } + + return compiled; + } + + private static string[] CompileMethods(List methods, string ruleDisplayName) + { + var compiled = new string[methods.Count]; + for (var i = 0; i < methods.Count; i++) + { + if (string.IsNullOrWhiteSpace(methods[i])) + { + throw new InvalidOperationException($"{ruleDisplayName} contains an empty Match.Methods entry."); + } + + compiled[i] = methods[i]; + } + + return compiled; + } + + private static RequestQueryParameterMatcher[] CompileQueryParameters( + List queryParameters, + string ruleDisplayName) + { + var compiled = new RequestQueryParameterMatcher[queryParameters.Count]; + for (var i = 0; i < queryParameters.Count; i++) + { + // Parse YARP-style query match entries. Example: + // { Name: "preview", Values: [ "true" ], Mode: "Exact" } matches "?preview=true". + compiled[i] = new RequestQueryParameterMatcher(queryParameters[i], ruleDisplayName); + } + + return compiled; + } + + private bool MatchHost(HostString host) + => _hosts.Length == 0 || (host.HasValue && HostString.MatchesAny(new StringSegment(host.Value), _hosts)); + + private bool MatchMethod(string method) + => _methods.Length == 0 || _methods.Any(candidate => string.Equals(candidate, method, StringComparison.OrdinalIgnoreCase)); + + private bool MatchQuery(IQueryCollection query) + { + foreach (var matcher in _queryMatchers) + { + if (!matcher.Match(query)) + { + return false; + } + } + + return true; + } +} diff --git a/src/Application/Features/RequestQueryParameterMatcher.cs b/src/Application/Features/RequestQueryParameterMatcher.cs new file mode 100644 index 000000000..8a92febf9 --- /dev/null +++ b/src/Application/Features/RequestQueryParameterMatcher.cs @@ -0,0 +1,159 @@ +// 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.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.Extensions.Primitives; +using Yarp.ReverseProxy.Configuration; + +namespace Yarp.Application.Features; + +internal sealed class RequestQueryParameterMatcher +{ + public RequestQueryParameterMatcher(RouteQueryParameter queryParameter, string ruleDisplayName) + { + if (string.IsNullOrEmpty(queryParameter.Name)) + { + throw new InvalidOperationException($"{ruleDisplayName} query parameter match requires a non-empty Name."); + } + + if (queryParameter.Mode != QueryParameterMatchMode.Exists + && (queryParameter.Values is null || queryParameter.Values.Count == 0)) + { + throw new InvalidOperationException($"{ruleDisplayName} query parameter match '{queryParameter.Name}' requires at least one value."); + } + + if (queryParameter.Mode == QueryParameterMatchMode.Exists && queryParameter.Values?.Count > 0) + { + throw new InvalidOperationException($"{ruleDisplayName} query parameter match '{queryParameter.Name}' must not specify values when Mode is Exists."); + } + + if (queryParameter.Values is not null && queryParameter.Values.Any(string.IsNullOrEmpty)) + { + throw new InvalidOperationException($"{ruleDisplayName} query parameter match '{queryParameter.Name}' contains an empty value."); + } + + Name = queryParameter.Name; + Values = queryParameter.Values?.ToArray() ?? []; + Mode = queryParameter.Mode; + Comparison = queryParameter.IsCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + } + + public string Name { get; } + + public string[] Values { get; } + + public QueryParameterMatchMode Mode { get; } + + private StringComparison Comparison { get; } + + public bool Match(IQueryCollection query) + { + query.TryGetValue(Name, out var requestQueryParameterValues); + var valueIsEmpty = StringValues.IsNullOrEmpty(requestQueryParameterValues); + + return Mode switch + { + QueryParameterMatchMode.Exists => !valueIsEmpty, + QueryParameterMatchMode.Exact => !valueIsEmpty && TryMatch(requestQueryParameterValues), + QueryParameterMatchMode.Prefix => !valueIsEmpty && TryMatch(requestQueryParameterValues), + QueryParameterMatchMode.Contains => !valueIsEmpty && TryMatch(requestQueryParameterValues), + QueryParameterMatchMode.NotContains => valueIsEmpty || TryMatch(requestQueryParameterValues), + _ => false + }; + } + + private bool TryMatch(StringValues requestQueryParameterValues) + { + for (var i = 0; i < requestQueryParameterValues.Count; i++) + { + var requestValue = requestQueryParameterValues[i]; + if (requestValue is null) + { + continue; + } + + foreach (var expectedValue in Values) + { + if (TryMatch(requestValue, expectedValue)) + { + return Mode != QueryParameterMatchMode.NotContains; + } + } + } + + return Mode == QueryParameterMatchMode.NotContains; + } + + private bool TryMatch(string queryValue, string expectedValue) + => Mode switch + { + QueryParameterMatchMode.Exact => queryValue.Equals(expectedValue, Comparison), + QueryParameterMatchMode.Prefix => queryValue.StartsWith(expectedValue, Comparison), + _ => queryValue.Contains(expectedValue, Comparison) + }; +} + +internal sealed class RequestQueryParameterMetadata +{ + public RequestQueryParameterMetadata(RequestQueryParameterMatcher[] matchers) + { + Matchers = matchers; + } + + public RequestQueryParameterMatcher[] Matchers { get; } +} + +internal sealed class RequestQueryParameterMatcherPolicy : MatcherPolicy, IEndpointComparerPolicy, IEndpointSelectorPolicy +{ + public override int Order => -25; + + public IComparer Comparer { get; } = Comparer.Create(static (x, y) => + (y.Metadata.GetMetadata()?.Matchers.Length ?? 0) + .CompareTo(x.Metadata.GetMetadata()?.Matchers.Length ?? 0)); + + bool IEndpointSelectorPolicy.AppliesToEndpoints(IReadOnlyList endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + if (ContainsDynamicEndpoints(endpoints)) + { + return true; + } + + return endpoints.Any(static endpoint => + endpoint.Metadata.GetMetadata()?.Matchers.Length > 0); + } + + public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(candidates); + + for (var i = 0; i < candidates.Count; i++) + { + if (!candidates.IsValidCandidate(i)) + { + continue; + } + + var matchers = candidates[i].Endpoint.Metadata.GetMetadata()?.Matchers; + if (matchers is null) + { + continue; + } + + foreach (var matcher in matchers) + { + if (!matcher.Match(httpContext.Request.Query)) + { + candidates.SetValidity(i, false); + break; + } + } + } + + return Task.CompletedTask; + } +} diff --git a/src/Application/Features/ResponseHeadersFeature.cs b/src/Application/Features/ResponseHeadersFeature.cs new file mode 100644 index 000000000..bc226656d --- /dev/null +++ b/src/Application/Features/ResponseHeadersFeature.cs @@ -0,0 +1,131 @@ +// 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.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.StaticFiles; +using Yarp.Application.Configuration; +using Yarp.ReverseProxy.Model; + +namespace Yarp.Application.Features; + +public static class ResponseHeadersFeature +{ + public static WebApplication UseResponseHeaders(this WebApplication app, YarpAppConfig config) + { + var headerRules = CompileHeaderRules(config); + if (headerRules.Length == 0) + { + return app; + } + + app.Use((context, next) => + { + if (IsProxyEndpoint(context)) + { + return next(); + } + + var requestPath = context.Request.Path; + + context.Response.OnStarting( + static state => + { + var (httpContext, originalPath, rules) = ((HttpContext, PathString, CompiledHeaderRule[]))state; + if (!IsSupersededByStatusCodeReExecute(httpContext, originalPath)) + { + ApplyHeaders(httpContext, originalPath, httpContext.Response.Headers, rules); + } + + return Task.CompletedTask; + }, + (context, requestPath, headerRules)); + + return next(); + }); + + return app; + } + + internal static Action? CreateStaticFileHeaderCallback(YarpAppConfig config) + { + var headerRules = CompileHeaderRules(config); + if (headerRules.Length == 0) + { + return null; + } + + // StaticFileMiddleware doesn't create endpoints, so apply the same header rules through + // OnPrepareResponse to keep static files aligned with routed app-generated responses. + return context => ApplyHeaders(context.Context, context.Context.Request.Path, context.Context.Response.Headers, headerRules); + } + + internal static CompiledHeaderRule[] CompileHeaderRules(YarpAppConfig config) + => config.Headers.Select(rule => new CompiledHeaderRule(rule)).ToArray(); + + internal static void ApplyHeaders(HttpContext context, PathString requestPath, IHeaderDictionary headers, CompiledHeaderRule[] headerRules) + { + foreach (var headerRule in headerRules) + { + headerRule.Apply(context, requestPath, headers); + } + } + + private static bool IsProxyEndpoint(HttpContext context) + => context.GetEndpoint()?.Metadata.GetMetadata() is not null; + + private static bool IsSupersededByStatusCodeReExecute(HttpContext context, PathString registeredPath) + => context.Features.Get() is not null + && !string.Equals(context.Request.Path.Value, registeredPath.Value, StringComparison.Ordinal); + + internal sealed class CompiledHeaderRule + { + private readonly RequestMatchEvaluator _matcher; + private readonly KeyValuePair[] _headers; + + public CompiledHeaderRule(HeaderRule rule) + { + if (rule.Set.Count == 0) + { + throw new InvalidOperationException( + $"Header rule '{rule.Match.Path ?? ""}' must set at least one header."); + } + + _matcher = new RequestMatchEvaluator(rule.Match, "Header rules"); + _headers = rule.Set + .Select(pair => + { + if (string.IsNullOrWhiteSpace(pair.Key)) + { + throw new InvalidOperationException( + $"Header rule '{rule.Match.Path ?? ""}' contains an empty header name."); + } + + if (pair.Value is null) + { + throw new InvalidOperationException( + $"Header rule '{rule.Match.Path ?? ""}' contains a null value for header '{pair.Key}'."); + } + + return new KeyValuePair(pair.Key, pair.Value); + }) + .ToArray(); + } + + public void Apply(HttpContext context, PathString requestPath, IHeaderDictionary headers) + { + if (!_matcher.TryMatch(context, requestPath, new())) + { + return; + } + + foreach (var header in _headers) + { + // Rules are additive at the config level, but later matches overwrite the same + // header name so users can layer broad defaults with narrow exceptions. + headers[header.Key] = header.Value; + } + } + } +} diff --git a/src/Application/Features/RewritesFeature.cs b/src/Application/Features/RewritesFeature.cs new file mode 100644 index 000000000..b94f6a4f7 --- /dev/null +++ b/src/Application/Features/RewritesFeature.cs @@ -0,0 +1,58 @@ +// 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.Rewrite; +using Yarp.Application.Configuration; + +namespace Yarp.Application.Features; + +public static class RewritesFeature +{ + /// + /// Wires rules into the pipeline before + /// UseRouting, so every downstream stage (route matching, static files, redirects, + /// fallback, proxy) sees the rewritten path. Uses the standard ASP.NET rewrite-middleware + /// regex/$n syntax — no custom matching. + /// + public static WebApplication UseRewrites(this WebApplication app, YarpAppConfig config) + { + if (config.Rewrites.Count == 0) + { + return app; + } + + var options = new RewriteOptions(); + for (var i = 0; i < config.Rewrites.Count; i++) + { + var rule = config.Rewrites[i]; + if (string.IsNullOrWhiteSpace(rule.Regex)) + { + throw new InvalidOperationException( + $"Rewrite rule at index {i} requires a non-empty Regex."); + } + + if (rule.Replacement is null) + { + throw new InvalidOperationException( + $"Rewrite rule '{rule.Regex}' requires a Replacement."); + } + + try + { + // Parse the ASP.NET rewrite-middleware regex/replacement pair. Example: + // Regex "^docs/current/(.*)$" and Replacement "docs/v2/$1" rewrites + // "/docs/current/intro.html" to "/docs/v2/intro.html". + options.AddRewrite(rule.Regex, rule.Replacement, rule.SkipRemainingRules); + } + catch (ArgumentException ex) + { + throw new InvalidOperationException( + $"Rewrite rule at index {i} has an invalid Regex pattern '{rule.Regex}': {ex.Message}", ex); + } + } + + app.UseRewriter(options); + return app; + } +} diff --git a/src/Application/Features/StaticFilesFeature.cs b/src/Application/Features/StaticFilesFeature.cs index 32dca2a31..4b1c48608 100644 --- a/src/Application/Features/StaticFilesFeature.cs +++ b/src/Application/Features/StaticFilesFeature.cs @@ -2,18 +2,76 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Yarp.Application.Configuration; namespace Yarp.Application.Features; public static class StaticFilesFeature { + private static readonly object PreservedEndpointKey = new(); + public static WebApplication UseStaticFiles(this WebApplication app, YarpAppConfig config) { - if (config.StaticFiles.Enabled) + if (!config.StaticFiles.Enabled) + { + return app; + } + + app.Use((context, next) => + { + var endpoint = context.GetEndpoint(); + if (endpoint?.RequestDelegate is null) + { + context.Items.Remove(PreservedEndpointKey); + return next(); + } + + // Redirects short-circuit during UseRouting, but normal routed endpoints would block + // StaticFileMiddleware once UseRouting runs first. Clear the selected endpoint around + // UseFileServer so static files still win when a physical file exists, then restore it + // for later middleware and endpoint execution if no file handled the request. + context.Items[PreservedEndpointKey] = endpoint; + context.SetEndpoint(null); + return next(); + }); + + var onPrepareResponse = ResponseHeadersFeature.CreateStaticFileHeaderCallback(config); + if (onPrepareResponse is null) { app.UseFileServer(); } + else + { + // UseFileServer keeps default documents + static file serving together; only the + // response preparation callback changes when header rules are configured. + app.UseFileServer(new FileServerOptions + { + StaticFileOptions = + { + OnPrepareResponse = onPrepareResponse + } + }); + } + + app.Use((context, next) => + { + if (context.Items.TryGetValue(PreservedEndpointKey, out var endpoint)) + { + // The same HttpContext can be re-executed by StatusCodePages. Once this saved + // endpoint has been considered, remove it so a re-executed error-page path cannot + // accidentally restore the original request's endpoint. + context.Items.Remove(PreservedEndpointKey); + if (!context.Response.HasStarted + && context.GetEndpoint() is null + && endpoint is Endpoint preservedEndpoint) + { + context.SetEndpoint(preservedEndpoint); + } + } + + return next(); + }); return app; } diff --git a/src/Application/Program.cs b/src/Application/Program.cs index ead454944..dc564e197 100644 --- a/src/Application/Program.cs +++ b/src/Application/Program.cs @@ -65,9 +65,16 @@ LoggingFeature.PrintBanner(config, configFilePath, app); // Middleware pipeline — order matters -app.UseStaticFiles(config); +app.UseRewrites(config); +// StatusCodePages wraps the rest of the pipeline, so register it before anything +// that can produce the 4xx/5xx response it may re-execute. +app.UseErrorPages(config); app.UseRouting(); +app.UseStaticFiles(config); +app.UseResponseHeaders(config); +app.MapRedirects(config); app.MapReverseProxy(); +app.MapNavigationFallbackExclusions(config); app.MapNavigationFallback(config); await app.RunAsync(); diff --git a/src/Application/README.md b/src/Application/README.md index a597ca668..97b1335d2 100644 --- a/src/Application/README.md +++ b/src/Application/README.md @@ -75,6 +75,65 @@ Simple toggles work as environment variables. Complex config (proxy routes, etc. All configuration goes through `IConfiguration` — JSON files, environment variables, or any other provider. See [`yarp-config.schema.json`](yarp-config.schema.json) for IDE autocomplete and validation. +### Request pipeline + +Every request flows through the pipeline below in this fixed order. Knowing the order is usually enough to reason about which feature wins for a given URL. + +```text +┌─────────────────────────────────────────┐ +│ 1. Rewrites (pre-routing) │ Regex-based path rewrite. Mutates Request.Path. +├─────────────────────────────────────────┤ +│ 2. Error pages (wraps everything)│ Re-execute against a configured page on 4xx/5xx. +├─────────────────────────────────────────┤ +│ 3. Routing match (endpoint chosen) │ ASP.NET selects an endpoint, but doesn't run it yet. +├─────────────────────────────────────────┤ +│ 4. Redirects (short-circuit) │ If a redirect endpoint matched, send 30x and stop. +├─────────────────────────────────────────┤ +│ 5. Static files (special) │ If a file at Request.Path exists in wwwroot, serve it. +├─────────────────────────────────────────┤ +│ 6. Headers (response phase) │ Apply Header rules to non-proxy app responses. +├─────────────────────────────────────────┤ +│ 7. Reverse proxy (endpoint) │ YARP routes that matched in step 3 run here. +├─────────────────────────────────────────┤ +│ 8. Fallback exclude (endpoint) │ Listed paths return 404 instead of falling back. +├─────────────────────────────────────────┤ +│ 9. SPA fallback (endpoint) │ Otherwise serve NavigationFallback.Path (e.g. index.html). +└─────────────────────────────────────────┘ +``` + +A few consequences worth internalizing: + +- **Rewrites run first**, so every later stage sees the rewritten path. Use them to canonicalize URLs before anything else makes a decision. +- **Redirects beat static files and the proxy.** If a redirect rule matches, the response is a 30x — the file or upstream is never consulted. +- **Static files beat fallback exclusions and the SPA fallback.** A real file in `wwwroot` always wins over routed fallback endpoints, even though those fallbacks were chosen by `UseRouting` first. (This is preserved by clearing/restoring the selected endpoint around `UseFileServer`.) +- **`Headers` apply to app-generated responses** — static files, redirects, fallback exclusions, SPA fallback, and custom error pages. They do not apply to proxy responses; use YARP response transforms for proxy headers. +- **Fallback exclusions and the SPA fallback are real routed endpoints**, so reverse-proxy routes (and any `MapGet`/`MapPost` registered earlier) can still claim a path before either fires. + +### Match syntax + +Routed features (`Headers`, `Redirects`, `NavigationFallback.Exclude`) match using YARP-style route criteria. `Match.Path` uses ASP.NET route templates — `/blog/{slug}`, `/api/{**catch-all}` — the same engine that powers `MapGet`. Omit `Path` to match all paths. Captures from `Match.Path` are available in `Destination` as `{name}` substitutions. + +`Match.Hosts`, `Match.Methods`, and `Match.QueryParameters` narrow the same rule. Hosts use endpoint-routing host patterns (`example.com`, `*.example.com`, `example.com:8443`). Query parameters use YARP's route-match modes: `Exact`, `Contains`, `NotContains`, `Prefix`, and `Exists`. + +```json +{ + "Match": { + "Path": "/docs/{slug}", + "Hosts": [ "example.com", "*.example.com" ], + "Methods": [ "GET", "HEAD" ], + "QueryParameters": [ + { + "Name": "preview", + "Values": [ "true" ], + "Mode": "Exact" + } + ] + } +} +``` + +`Rewrites` use a different syntax — regex with `$n` capture groups — because they delegate to the standard ASP.NET [URL rewrite middleware](https://learn.microsoft.com/aspnet/core/fundamentals/url-rewriting). This is intentional: routed features are routes (so they use route-template syntax), and rewrites are rewrites (so they use the existing rewrite syntax). No new syntax is introduced. + ### `StaticFiles` Serve static files from `wwwroot/`. @@ -88,9 +147,116 @@ Serve static files from `wwwroot/`. SPA fallback — serve a file (typically `index.html`) for unmatched routes so client-side routing works. ```json -{ "NavigationFallback": { "Path": "/index.html" } } +{ + "NavigationFallback": { + "Path": "/index.html", + "Exclude": [ + { "Path": "/api/{**catch-all}" }, + { "Path": "/.well-known/{**catch-all}" } + ] + } +} +``` + +### `Headers` + +Response header rules for app-generated responses. All matching rules are applied. Proxy responses are intentionally excluded; use YARP response transforms for proxy headers. + +```json +{ + "Headers": [ + { + "Match": { + "Path": "/{**path}" + }, + "Set": { + "X-Content-Type-Options": "nosniff" + } + }, + { + "Match": { + "Path": "/_astro/{**path}" + }, + "Set": { + "Cache-Control": "public, max-age=31536000, immutable" + } + } + ] +} +``` + +### `Redirects` + +Declarative redirects. Rules are evaluated in order and the first match wins. + +```json +{ + "Redirects": [ + { + "Match": { + "Path": "/old-page" + }, + "Destination": "/new-page", + "StatusCode": 301 + }, + { + "Match": { + "Path": "/install.sh" + }, + "Destination": "https://aka.ms/install.sh", + "StatusCode": 302 + } + ] +} +``` + +`Destination` can reference route values captured by `Match.Path`, such as `{slug}`. + +### `Rewrites` + +URL rewrites applied **before routing**, so every downstream stage (routing, static files, redirects, SPA fallback, reverse proxy) sees the rewritten path. Uses the standard [ASP.NET rewrite middleware](https://learn.microsoft.com/aspnet/core/fundamentals/url-rewriting) — regex pattern + `$n` capture-group substitution. By default, the first matching rule wins. + +```json +{ + "Rewrites": [ + { + "Regex": "^blog/(.*)$", + "Replacement": "posts/$1" + }, + { + "Regex": "^legacy/(.*)$", + "Replacement": "$1" + } + ] +} +``` + +Set `SkipRemainingRules: false` to allow subsequent rules to also evaluate against the rewritten path. + +### `ErrorPages` + +Custom error pages for 4xx/5xx responses. The request is internally re-executed against the configured path while the original status code is preserved (the browser still sees `404`, `500`, etc.). Built on top of ASP.NET's [`UseStatusCodePages`](https://learn.microsoft.com/aspnet/core/fundamentals/error-handling#usestatuscodepageswithreexecute) — only fires when the response has not started and the body is empty. + +```json +{ + "ErrorPages": { + "404": "/404.html", + "503": "/maintenance.html", + "4xx": "/client-error.html", + "5xx": "/server-error.html" + } +} ``` +Keys are either a 3-digit HTTP status code (`"404"`) or a class wildcard (`"4xx"`, `"5xx"`). Values must be rooted request paths that start with `/`. **Exact codes win over wildcards**, so in the example above: + +| Status | Page | +| --- | --- | +| 400, 403 | `/client-error.html` | +| 404 | `/404.html` | +| 500, 502 | `/server-error.html` | +| 503 | `/maintenance.html` | + ### `ReverseProxy` YARP reverse proxy routes and clusters. See the [YARP configuration docs](https://learn.microsoft.com/aspnet/core/fundamentals/servers/yarp/config-files) for the full reference. @@ -131,12 +297,22 @@ This is an opinionated, pre-built application — not an extensible framework. U Configuration/ Config model (IConfiguration → POCOs) YarpAppConfig.cs Root config object YarpAppConfigBinder.cs Single conversion point + legacy key mapping + RequestMatch.cs Shared route-style match object StaticFilesOptions.cs Per-feature options NavigationFallbackOptions.cs + HeaderRule.cs + RedirectRule.cs + RewriteRule.cs TelemetryOptions.cs Features/ Per-feature extension methods + RequestMatchEvaluator.cs Route-template-based match evaluation StaticFilesFeature.cs NavigationFallbackFeature.cs + NavigationFallbackExclusionsFeature.cs + RedirectsFeature.cs + RewritesFeature.cs + ErrorPagesFeature.cs + ResponseHeadersFeature.cs ReverseProxyFeature.cs LoggingFeature.cs Program.cs Pipeline ordering diff --git a/src/Application/yarp-config.schema.json b/src/Application/yarp-config.schema.json index 35a561486..39e4fc763 100644 --- a/src/Application/yarp-config.schema.json +++ b/src/Application/yarp-config.schema.json @@ -3,6 +3,69 @@ "title": "YARP Container Configuration", "description": "Configuration schema for the YARP container application", "type": "object", + "definitions": { + "requestMatch": { + "type": "object", + "description": "Request match criteria aligned with YARP route matching. Path uses ASP.NET route pattern syntax; omitted Path matches all paths.", + "properties": { + "Path": { + "type": "string", + "description": "Request path route pattern to match" + }, + "Hosts": { + "type": "array", + "description": "Host patterns to match. Supports endpoint-routing host patterns such as example.com, *.example.com, and example.com:8443.", + "items": { + "type": "string" + } + }, + "Methods": { + "type": "array", + "description": "HTTP methods to match, such as GET, HEAD, or POST.", + "items": { + "type": "string" + } + }, + "QueryParameters": { + "type": "array", + "description": "YARP-style query parameter matchers. All configured query parameters must match.", + "items": { + "$ref": "#/definitions/queryParameterMatch" + } + } + }, + "additionalProperties": false + }, + "queryParameterMatch": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Query parameter name to match" + }, + "Values": { + "type": "array", + "description": "Allowed values. Required unless Mode is Exists.", + "items": { + "type": "string" + } + }, + "Mode": { + "type": "string", + "enum": ["Exact", "Contains", "NotContains", "Prefix", "Exists"], + "default": "Exact", + "description": "How query parameter values are compared" + }, + "IsCaseSensitive": { + "type": "boolean", + "default": false, + "description": "Whether value matching is case-sensitive" + } + }, + "required": ["Name"], + "additionalProperties": false + } + }, "properties": { "StaticFiles": { "type": "object", @@ -23,6 +86,99 @@ "Path": { "type": "string", "description": "File to serve for unmatched non-file routes (e.g., /index.html)" + }, + "Exclude": { + "type": "array", + "description": "Route-style path matches that bypass SPA fallback", + "items": { + "$ref": "#/definitions/requestMatch" + } + } + }, + "additionalProperties": false + }, + "Headers": { + "type": "array", + "description": "Response header rules for app-generated responses. Proxy responses are excluded; use YARP response transforms for proxy headers. All matching rules are applied.", + "items": { + "type": "object", + "properties": { + "Match": { + "$ref": "#/definitions/requestMatch" + }, + "Set": { + "type": "object", + "description": "Headers to set on matching responses", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["Match", "Set"], + "additionalProperties": false + } + }, + "Redirects": { + "type": "array", + "description": "Redirect rules. First matching rule wins.", + "items": { + "type": "object", + "properties": { + "Match": { + "$ref": "#/definitions/requestMatch" + }, + "Destination": { + "type": "string", + "description": "Redirect target (relative or absolute)" + }, + "StatusCode": { + "type": "integer", + "enum": [301, 302, 307, 308], + "default": 301, + "description": "HTTP redirect status code" + } + }, + "required": ["Match", "Destination"], + "additionalProperties": false + } + }, + "Rewrites": { + "type": "array", + "description": "URL rewrite rules applied before routing using the ASP.NET rewrite middleware (regex syntax).", + "items": { + "type": "object", + "properties": { + "Regex": { + "type": "string", + "description": "Regular expression matched against the request path" + }, + "Replacement": { + "type": "string", + "description": "Replacement string. May reference capture groups via $1, $2, etc." + }, + "SkipRemainingRules": { + "type": "boolean", + "default": true, + "description": "If true (default), skip remaining rules when this rule matches" + } + }, + "required": ["Regex", "Replacement"], + "additionalProperties": false + } + }, + "ErrorPages": { + "type": "object", + "description": "Custom error pages, re-executed against the configured path while preserving the original status code. Keys are 3-digit status codes (e.g. '404') or class wildcards (e.g. '5xx'); exact codes win over wildcards.", + "patternProperties": { + "^[0-9]{3}$": { + "type": "string", + "pattern": "^/", + "description": "Rooted request path for the custom error page (e.g. /404.html)" + }, + "^[0-9][xX][xX]$": { + "type": "string", + "pattern": "^/", + "description": "Rooted request path for the custom error page (e.g. /server-error.html)" } }, "additionalProperties": false diff --git a/test/Application.Tests/SpaFallbackTests.cs b/test/Application.Tests/SpaFallbackTests.cs index 9d432a813..7e97cc86f 100644 --- a/test/Application.Tests/SpaFallbackTests.cs +++ b/test/Application.Tests/SpaFallbackTests.cs @@ -3,11 +3,19 @@ using System.Net; using System.Text.Json; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Xunit; using Yarp.Application.Configuration; +using Yarp.Application.Features; +using Yarp.ReverseProxy.Configuration; namespace Yarp.Application.Tests; @@ -75,6 +83,34 @@ private static YarpTestApp CreateApp(Dictionary? config = null) ["YARP_ENABLE_STATIC_FILES"] = "true" }; + private static HttpClient CreateNoRedirectClient(WebApplicationFactory factory) + => factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + private static async Task CreateBackendAsync(RequestDelegate requestDelegate) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseKestrel().UseUrls("http://127.0.0.1:0"); + + var app = builder.Build(); + app.Run(requestDelegate); + await app.StartAsync(); + return app; + } + + private static string GetAddress(WebApplication app) + { + var addresses = app.Services.GetRequiredService() + .Features.Get()?.Addresses; + + var address = addresses?.Single() + ?? throw new InvalidOperationException("The test backend did not publish an address."); + + return address.EndsWith('/') ? address : $"{address}/"; + } + [Fact] public async Task SpaFallback_ReturnsIndexHtml_ForUnknownRoutes() { @@ -234,6 +270,25 @@ public async Task SpaFallback_CatchAllYarpRoute_WinsOverFallback() Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); } + [Fact] + public async Task StaticFiles_StillWinOverCatchAllYarpRoute() + { + using var factory = CreateApp(new Dictionary() + { + ["YARP_ENABLE_STATIC_FILES"] = "true", + ["ReverseProxy:Routes:catchall:ClusterId"] = "backend", + ["ReverseProxy:Routes:catchall:Match:Path"] = "{**catch-all}", + ["ReverseProxy:Clusters:backend:Destinations:d1:Address"] = "https://localhost:9999" + }); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/style.css"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("color: red", content); + } + // Tests using the strongly-typed config object model [Fact] @@ -287,6 +342,509 @@ public async Task ObjectModel_CustomFallbackPath() Assert.Contains("SPA Index", content); } + [Fact] + public async Task ObjectModel_FallbackExclude_Returns404_ForExcludedPaths() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/api/{**catch-all}" } } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/api/v1/users"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task ObjectModel_FallbackExclude_MatchesDottedFileLikePaths() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/.well-known/{**catch-all}" } } + } + }); + using var client = app.CreateClient(); + + // This looks like a file request, so MapFallback would not catch it. The exclusion must + // still win so /.well-known assets do not accidentally fall back to the SPA shell. + var response = await client.GetAsync("/.well-known/assetlinks.json"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.DoesNotContain("SPA Index", content); + } + + [Fact] + public async Task ObjectModel_FallbackExclude_DoesNotAffectOtherSpaRoutes() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/api/{**catch-all}" } } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/dashboard/settings"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("SPA Index", content); + } + + [Fact] + public async Task ObjectModel_FallbackExclude_DoesNotAffectStaticFiles() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/style.css" } } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/style.css"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("color: red", content); + } + + [Fact] + public async Task ObjectModel_FallbackExclude_DoesNotAffectReverseProxyRoutes() + { + using var factory = CreateApp(new Dictionary() + { + ["StaticFiles:Enabled"] = "true", + ["NavigationFallback:Path"] = "/index.html", + ["NavigationFallback:Exclude:0:Path"] = "/api/{**catch-all}", + ["ReverseProxy:Routes:api:ClusterId"] = "backend", + ["ReverseProxy:Routes:api:Match:Path"] = "/api/{**catch-all}", + ["ReverseProxy:Clusters:backend:Destinations:d1:Address"] = "https://localhost:9999" + }); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/ping"); + + Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task ObjectModel_FallbackExclude_CanMatchHostMethodAndQuery() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = + { + new RequestMatch + { + Path = "/api/{**catch-all}", + Hosts = { "example.com" }, + Methods = { "GET" }, + QueryParameters = + { + new RouteQueryParameter + { + Name = "skipFallback", + Mode = QueryParameterMatchMode.Exists + } + } + } + } + } + }); + using var client = app.CreateClient(); + + using var matchingRequest = new HttpRequestMessage(HttpMethod.Get, "/api/users?skipFallback=1"); + matchingRequest.Headers.Host = "example.com"; + var matchingResponse = await client.SendAsync(matchingRequest); + + using var missingQueryRequest = new HttpRequestMessage(HttpMethod.Get, "/api/users"); + missingQueryRequest.Headers.Host = "example.com"; + var missingQueryResponse = await client.SendAsync(missingQueryRequest); + + using var wrongHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/users?skipFallback=1"); + wrongHostRequest.Headers.Host = "other.example.com"; + var wrongHostResponse = await client.SendAsync(wrongHostRequest); + + Assert.Equal(HttpStatusCode.NotFound, matchingResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, missingQueryResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, wrongHostResponse.StatusCode); + } + + [Fact] + public async Task ObjectModel_HeaderRules_ApplyToStaticFiles() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Headers = + { + new HeaderRule + { + Match = new RequestMatch { Path = "/{**path}" }, + Set = { ["X-Test"] = "applied" } + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/style.css"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("applied", response.Headers.GetValues("X-Test").Single()); + } + + [Fact] + public async Task ObjectModel_HeaderRules_CanMatchHostMethodAndQuery() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Headers = + { + new HeaderRule + { + Match = new RequestMatch + { + Path = "/style.css", + Hosts = { "example.com" }, + Methods = { "GET" }, + QueryParameters = + { + new RouteQueryParameter + { + Name = "preview", + Values = ["true"], + Mode = QueryParameterMatchMode.Exact + } + } + }, + Set = { ["X-Matched"] = "yes" } + } + } + }); + using var client = app.CreateClient(); + + using var matchingRequest = new HttpRequestMessage(HttpMethod.Get, "/style.css?preview=true"); + matchingRequest.Headers.Host = "example.com"; + var matchingResponse = await client.SendAsync(matchingRequest); + + using var wrongQueryRequest = new HttpRequestMessage(HttpMethod.Get, "/style.css?preview=false"); + wrongQueryRequest.Headers.Host = "example.com"; + var wrongQueryResponse = await client.SendAsync(wrongQueryRequest); + + Assert.Equal(HttpStatusCode.OK, matchingResponse.StatusCode); + Assert.Equal("yes", matchingResponse.Headers.GetValues("X-Matched").Single()); + Assert.Equal(HttpStatusCode.OK, wrongQueryResponse.StatusCode); + Assert.False(wrongQueryResponse.Headers.Contains("X-Matched")); + } + + [Fact] + public async Task ObjectModel_HeaderRules_ApplyToFallbackResponses() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = { Path = "/index.html" }, + Headers = + { + new HeaderRule + { + Match = new RequestMatch { Path = "/{**path}" }, + Set = { ["X-Test"] = "applied" } + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/docs/spa/route"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("applied", response.Headers.GetValues("X-Test").Single()); + } + + [Fact] + public async Task HeaderRules_DoNotApplyToProxiedResponses() + { + using var factory = CreateApp(new Dictionary() + { + ["StaticFiles:Enabled"] = "true", + ["Headers:0:Match:Path"] = "/{**path}", + ["Headers:0:Set:X-Test"] = "applied", + ["ReverseProxy:Routes:api:ClusterId"] = "backend", + ["ReverseProxy:Routes:api:Match:Path"] = "/api/{**catch-all}", + ["ReverseProxy:Clusters:backend:Destinations:d1:Address"] = "https://localhost:9999" + }); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/ping"); + + Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("X-Test")); + } + + [Fact] + public async Task ObjectModel_HeaderRules_ApplyToRedirectResponses() + { + using var app = CreateApp(new YarpAppConfig + { + Headers = + { + new HeaderRule + { + Match = new RequestMatch { Path = "/old" }, + Set = { ["X-App-Header"] = "redirect" } + } + }, + Redirects = + { + new RedirectRule + { + Match = new RequestMatch { Path = "/old" }, + Destination = "/new", + StatusCode = 302 + } + } + }); + using var client = CreateNoRedirectClient(app); + + var response = await client.GetAsync("/old"); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Equal("redirect", response.Headers.GetValues("X-App-Header").Single()); + } + + [Fact] + public async Task ObjectModel_HeaderRules_ApplyToFallbackExclusionResponses() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/api/{**catch-all}" } } + }, + Headers = + { + new HeaderRule + { + Match = new RequestMatch { Path = "/api/{**catch-all}" }, + Set = { ["X-App-Header"] = "excluded" } + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/api/users"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("excluded", response.Headers.GetValues("X-App-Header").Single()); + } + + [Fact] + public async Task ObjectModel_Rewrites_AffectStaticHeaderMatching() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Headers = + { + new HeaderRule + { + Match = new RequestMatch { Path = "/style.css" }, + Set = { ["X-Rewritten-Static"] = "true" } + } + }, + Rewrites = + { + new RewriteRule + { + Regex = "^legacy-style$", + Replacement = "style.css" + } + } + }); + using var client = app.CreateClient(); + + // Rewrites run before static files and header matching, so the header rule sees + // /style.css rather than the original /legacy-style request path. + var response = await client.GetAsync("/legacy-style"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("true", response.Headers.GetValues("X-Rewritten-Static").Single()); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("color: red", content); + } + + [Fact] + public async Task ObjectModel_Redirects_RunBeforeStaticFiles() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Redirects = + { + new RedirectRule + { + Match = new RequestMatch { Path = "/style.css" }, + Destination = "/redirected.css", + StatusCode = 302 + } + } + }); + using var client = CreateNoRedirectClient(app); + + var response = await client.GetAsync("/style.css"); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Equal("/redirected.css", response.Headers.Location?.ToString()); + } + + [Fact] + public async Task Redirects_RunBeforeReverseProxy() + { + using var factory = CreateApp(new Dictionary() + { + ["Redirects:0:Match:Path"] = "/api/{**catch-all}", + ["Redirects:0:Destination"] = "/docs", + ["Redirects:0:StatusCode"] = "302", + ["ReverseProxy:Routes:api:ClusterId"] = "backend", + ["ReverseProxy:Routes:api:Match:Path"] = "/api/{**catch-all}", + ["ReverseProxy:Clusters:backend:Destinations:d1:Address"] = "https://localhost:9999" + }); + using var client = CreateNoRedirectClient(factory); + + var response = await client.GetAsync("/api/ping"); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Equal("/docs", response.Headers.Location?.ToString()); + } + + [Fact] + public async Task ObjectModel_Redirects_RunBeforeFallbackExclusions() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/api/{**catch-all}" } } + }, + Redirects = + { + new RedirectRule + { + Match = new RequestMatch { Path = "/api/old" }, + Destination = "/api/new", + StatusCode = 302 + } + } + }); + using var client = CreateNoRedirectClient(app); + + // Both the redirect and exclusion match. Redirects use an earlier endpoint order, so + // the request redirects instead of becoming the exclusion's 404. + var response = await client.GetAsync("/api/old"); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Equal("/api/new", response.Headers.Location?.ToString()); + } + + [Fact] + public async Task ObjectModel_Redirects_CanUseRouteValuesInDestination() + { + using var app = CreateApp(new YarpAppConfig + { + Redirects = + { + new RedirectRule + { + Match = new RequestMatch { Path = "/docs/{**slug}" }, + Destination = "/articles/{slug}", + StatusCode = 302 + } + } + }); + using var client = CreateNoRedirectClient(app); + + var response = await client.GetAsync("/docs/getting-started/install"); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Equal("/articles/getting-started/install", response.Headers.Location?.ToString()); + } + + [Fact] + public async Task ObjectModel_Redirects_CanMatchHostMethodAndQuery() + { + using var app = CreateApp(new YarpAppConfig + { + Redirects = + { + new RedirectRule + { + Match = new RequestMatch + { + Path = "/old/{slug}", + Hosts = { "example.com" }, + Methods = { "POST" }, + QueryParameters = + { + new RouteQueryParameter + { + Name = "preview", + Values = ["true"], + Mode = QueryParameterMatchMode.Exact + } + } + }, + Destination = "/new/{slug}", + StatusCode = 302 + } + } + }); + using var client = CreateNoRedirectClient(app); + + using var getRequest = new HttpRequestMessage(HttpMethod.Get, "/old/page?preview=true"); + getRequest.Headers.Host = "example.com"; + var getResponse = await client.SendAsync(getRequest); + + using var wrongQueryRequest = new HttpRequestMessage(HttpMethod.Post, "/old/page?preview=false"); + wrongQueryRequest.Headers.Host = "example.com"; + var wrongQueryResponse = await client.SendAsync(wrongQueryRequest); + + using var matchingRequest = new HttpRequestMessage(HttpMethod.Post, "/old/page?preview=true"); + matchingRequest.Headers.Host = "example.com"; + var matchingResponse = await client.SendAsync(matchingRequest); + + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, wrongQueryResponse.StatusCode); + Assert.Equal(HttpStatusCode.Found, matchingResponse.StatusCode); + Assert.Equal("/new/page", matchingResponse.Headers.Location?.ToString()); + } + [Fact] public async Task ObjectModel_EverythingDisabled() { @@ -299,4 +857,446 @@ public async Task ObjectModel_EverythingDisabled() Assert.Equal(HttpStatusCode.NotFound, indexResponse.StatusCode); Assert.Equal(HttpStatusCode.NotFound, cssResponse.StatusCode); } + + [Fact] + public async Task ObjectModel_Rewrites_RewriteToStaticFile() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Rewrites = + { + new RewriteRule + { + Regex = "^styles$", + Replacement = "style.css" + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/styles"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("color: red", content); + } + + [Fact] + public async Task ObjectModel_Rewrites_SubstituteCaptureGroups() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Rewrites = + { + new RewriteRule + { + Regex = "^assets/(.*)$", + Replacement = "$1" + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/assets/style.css"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("color: red", content); + } + + [Fact] + public async Task ObjectModel_Rewrites_RunBeforeRouting_AffectFallbackExclude() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/api/{**catch-all}" } } + }, + Rewrites = + { + // /old-api/* gets rewritten to /api/* — the exclude rule should still match + new RewriteRule + { + Regex = "^old-api/(.*)$", + Replacement = "api/$1" + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/old-api/users"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task ObjectModel_Rewrites_RunBeforeRedirects() + { + using var app = CreateApp(new YarpAppConfig + { + Redirects = + { + new RedirectRule + { + Match = new RequestMatch { Path = "/new" }, + Destination = "/destination", + StatusCode = 302 + } + }, + Rewrites = + { + new RewriteRule + { + Regex = "^old$", + Replacement = "new" + } + } + }); + using var client = CreateNoRedirectClient(app); + + var response = await client.GetAsync("/old"); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Equal("/destination", response.Headers.Location?.ToString()); + } + + [Fact] + public async Task ObjectModel_Rewrites_FirstMatchWins_NoChaining() + { + // SkipRemainingRules defaults to true, so the second rule must NOT re-fire + // even though the rewritten path matches it. + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Rewrites = + { + new RewriteRule + { + Regex = "^a$", + Replacement = "b" + }, + new RewriteRule + { + Regex = "^b$", + Replacement = "style.css" + } + } + }); + using var client = app.CreateClient(); + + var responseA = await client.GetAsync("/a"); + var responseB = await client.GetAsync("/b"); + + // /a -> /b, no chaining, so /a returns 404 (no /b file) + Assert.Equal(HttpStatusCode.NotFound, responseA.StatusCode); + // /b matches the second rule directly and rewrites to /style.css + Assert.Equal(HttpStatusCode.OK, responseB.StatusCode); + } + + [Fact] + public async Task ObjectModel_Rewrites_NoMatch_PassesThroughUnchanged() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Rewrites = + { + new RewriteRule + { + Regex = "^blog/(.*)$", + Replacement = "posts/$1" + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/style.css"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("color: red", content); + } + + [Fact] + public async Task ObjectModel_Rewrites_AffectReverseProxyRouting() + { + using var factory = CreateApp(new Dictionary() + { + ["Rewrites:0:Regex"] = "^legacy/(.*)$", + ["Rewrites:0:Replacement"] = "api/$1", + ["ReverseProxy:Routes:api:ClusterId"] = "backend", + ["ReverseProxy:Routes:api:Match:Path"] = "/api/{**catch-all}", + ["ReverseProxy:Clusters:backend:Destinations:d1:Address"] = "https://localhost:9999" + }); + using var client = factory.CreateClient(); + + // Should hit the proxy route (which fails to connect, but routing matched). + var response = await client.GetAsync("/legacy/ping"); + + Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public void ObjectModel_Rewrites_InvalidRegex_ThrowsClearError() + { + using var app = CreateApp(new YarpAppConfig + { + Rewrites = + { + new RewriteRule { Regex = "[unterminated(", Replacement = "/x" } + } + }); + + var ex = Assert.ThrowsAny(() => app.CreateClient()); + Assert.Contains("invalid Regex pattern", ex.Message); + } + + [Fact] + public async Task ObjectModel_ErrorPages_ExactCode_ServesPageWithOriginalStatus() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + ErrorPages = { ["404"] = "/404.html" } + }); + using var client = app.CreateClient(); + + // Unknown path → 404 (no SPA fallback configured) → re-execute against /404.html + var response = await client.GetAsync("/missing"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Custom 404", content); + } + + [Fact] + public async Task ObjectModel_ErrorPages_StaticFilePageReceivesHeaderRules() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + ErrorPages = { ["404"] = "/404.html" }, + Headers = + { + new HeaderRule + { + Match = new RequestMatch { Path = "/404.html" }, + Set = { ["X-Error-Page"] = "static-file" } + } + } + }); + using var client = app.CreateClient(); + + // ErrorPages re-executes /404.html through static files, so app header rules + // still apply to the custom error page body while the original 404 status is preserved. + var response = await client.GetAsync("/missing"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("static-file", response.Headers.GetValues("X-Error-Page").Single()); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Custom 404", content); + } + + [Fact] + public async Task ObjectModel_ErrorPages_HeadersUseReExecutedPath() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + ErrorPages = { ["404"] = "/404.html" }, + Headers = + { + new HeaderRule + { + Match = new RequestMatch { Path = "/missing" }, + Set = { ["X-Error-Page"] = "original" } + }, + new HeaderRule + { + Match = new RequestMatch { Path = "/404.html" }, + Set = { ["X-Error-Page"] = "reexecuted" } + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/missing"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("reexecuted", response.Headers.GetValues("X-Error-Page").Single()); + } + + [Fact] + public async Task ObjectModel_ErrorPages_ClassWildcard_MatchesAllCodesInClass() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/api/{**catch-all}" } } + }, + ErrorPages = { ["4xx"] = "/404.html" } + }); + using var client = app.CreateClient(); + + // The exclusion produces a 404 → wildcard '4xx' rule fires. + var response = await client.GetAsync("/api/missing"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Custom 404", content); + } + + [Fact] + public async Task ObjectModel_ErrorPages_ExactBeatsWildcard() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + ErrorPages = + { + ["404"] = "/404.html", + ["4xx"] = "/server-error.html" + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/missing"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Custom 404", content); + } + + [Fact] + public async Task ObjectModel_ErrorPages_ProxiedPage_PreservesOriginalStatusWhenTargetWrites200() + { + await using var backend = await CreateBackendAsync(async context => + { + context.Response.StatusCode = StatusCodes.Status200OK; + await context.Response.WriteAsync("Proxied error page"); + }); + + using var app = CreateApp(new Dictionary + { + ["ErrorPages:404"] = "/error-page", + ["ReverseProxy:Routes:error:ClusterId"] = "backend", + ["ReverseProxy:Routes:error:Match:Path"] = "/error-page", + ["ReverseProxy:Clusters:backend:Destinations:d1:Address"] = GetAddress(backend) + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/missing"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Proxied error page", content); + } + + [Fact] + public async Task ObjectModel_ErrorPages_MissingPageDoesNotRestoreOriginalEndpointDuringReExecute() + { + var webRoot = Directory.CreateTempSubdirectory(); + try + { + await using var app = await CreateAppWithOriginalEndpointAsync(webRoot.FullName); + using var client = app.GetTestClient(); + + // /original produces an empty 404, so ErrorPages re-executes /missing-error-page.html. + // If StaticFilesFeature restores the preserved /original endpoint during that second + // pass, this counter would be incremented twice. + var response = await client.GetAsync("/original"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(1, app.Services.GetRequiredService().Count); + } + finally + { + webRoot.Delete(recursive: true); + } + } + + [Fact] + public async Task ObjectModel_ErrorPages_NoMatch_PassesThrough() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + // Only configure 5xx; a 404 should pass through unchanged. + ErrorPages = { ["5xx"] = "/server-error.html" } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/missing"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.DoesNotContain("Server error", content); + } + + [Fact] + public void ObjectModel_ErrorPages_InvalidKey_ThrowsClearError() + { + using var app = CreateApp(new YarpAppConfig + { + ErrorPages = { ["nope"] = "/x.html" } + }); + + var ex = Assert.ThrowsAny(() => app.CreateClient()); + Assert.Contains("3-digit status code", ex.Message); + } + + [Fact] + public void ObjectModel_ErrorPages_RelativePath_ThrowsClearError() + { + using var app = CreateApp(new YarpAppConfig + { + ErrorPages = { ["404"] = "404.html" } + }); + + var ex = Assert.ThrowsAny(() => app.CreateClient()); + Assert.Contains("must start with '/'", ex.Message); + } + + private static async Task CreateAppWithOriginalEndpointAsync(string webRoot) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + WebRootPath = webRoot + }); + builder.WebHost.UseTestServer(); + builder.Services.AddSingleton(); + + var config = new YarpAppConfig + { + StaticFiles = { Enabled = true }, + ErrorPages = { ["404"] = "/missing-error-page.html" } + }; + + var app = builder.Build(); + app.UseErrorPages(config); + app.UseRouting(); + app.UseStaticFiles(config); + app.MapGet("/original", context => + { + context.RequestServices.GetRequiredService().Count++; + context.Response.StatusCode = StatusCodes.Status404NotFound; + return Task.CompletedTask; + }); + + await app.StartAsync(); + return app; + } + + private sealed class OriginalEndpointCounter + { + public int Count { get; set; } + } } diff --git a/test/Application.Tests/YarpAppConfigBinderTests.cs b/test/Application.Tests/YarpAppConfigBinderTests.cs index c798fa801..062045866 100644 --- a/test/Application.Tests/YarpAppConfigBinderTests.cs +++ b/test/Application.Tests/YarpAppConfigBinderTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration; using Xunit; using Yarp.Application.Configuration; +using Yarp.ReverseProxy.Configuration; namespace Yarp.Application.Tests; @@ -33,6 +34,99 @@ public void Bind_NavigationFallbackPath() Assert.Equal("/app.html", config.NavigationFallback.Path); } + [Fact] + public void Bind_NavigationFallbackExclude() + { + var config = Bind(new() + { + ["NavigationFallback:Exclude:0:Path"] = "/api/{**catch-all}", + ["NavigationFallback:Exclude:1:Path"] = "/.well-known/{**catch-all}" + }); + Assert.Collection( + config.NavigationFallback.Exclude, + match => Assert.Equal("/api/{**catch-all}", match.Path), + match => Assert.Equal("/.well-known/{**catch-all}", match.Path)); + } + + [Fact] + public void Bind_HeaderRules() + { + var config = Bind(new() + { + ["Headers:0:Match:Path"] = "/{**path}", + ["Headers:0:Set:X-Test"] = "applied", + ["Headers:1:Match:Path"] = "/_astro/{**path}", + ["Headers:1:Set:Cache-Control"] = "public, max-age=31536000, immutable" + }); + + Assert.Collection( + config.Headers, + rule => + { + Assert.Equal("/{**path}", rule.Match.Path); + Assert.Equal("applied", rule.Set["X-Test"]); + }, + rule => + { + Assert.Equal("/_astro/{**path}", rule.Match.Path); + Assert.Equal("public, max-age=31536000, immutable", rule.Set["Cache-Control"]); + }); + } + + [Fact] + public void Bind_RedirectRules() + { + var config = Bind(new() + { + ["Redirects:0:Match:Path"] = "/old-page", + ["Redirects:0:Destination"] = "/new-page", + ["Redirects:0:StatusCode"] = "302" + }); + + var rule = Assert.Single(config.Redirects); + Assert.Equal("/old-page", rule.Match.Path); + Assert.Equal("/new-page", rule.Destination); + Assert.Equal(302, rule.StatusCode); + } + + [Fact] + public void Bind_RequestMatch_HostsMethodsAndQueryParameters() + { + var config = Bind(new() + { + ["Redirects:0:Match:Path"] = "/docs/{slug}", + ["Redirects:0:Match:Hosts:0"] = "example.com", + ["Redirects:0:Match:Hosts:1"] = "*.example.com", + ["Redirects:0:Match:Methods:0"] = "GET", + ["Redirects:0:Match:Methods:1"] = "HEAD", + ["Redirects:0:Match:QueryParameters:0:Name"] = "preview", + ["Redirects:0:Match:QueryParameters:0:Values:0"] = "true", + ["Redirects:0:Match:QueryParameters:0:Mode"] = "Exact", + ["Redirects:0:Match:QueryParameters:1:Name"] = "tenant", + ["Redirects:0:Match:QueryParameters:1:Mode"] = "Exists", + ["Redirects:0:Destination"] = "/new-docs/{slug}" + }); + + var match = Assert.Single(config.Redirects).Match; + Assert.Equal("/docs/{slug}", match.Path); + Assert.Equal(["example.com", "*.example.com"], match.Hosts); + Assert.Equal(["GET", "HEAD"], match.Methods); + Assert.Collection( + match.QueryParameters, + query => + { + Assert.Equal("preview", query.Name); + Assert.Equal(["true"], query.Values); + Assert.Equal(QueryParameterMatchMode.Exact, query.Mode); + }, + query => + { + Assert.Equal("tenant", query.Name); + Assert.Null(query.Values); + Assert.Equal(QueryParameterMatchMode.Exists, query.Mode); + }); + } + [Fact] public void Bind_TelemetryUnsafeCert() { @@ -46,6 +140,9 @@ public void Bind_Defaults_EverythingOff() var config = Bind(new()); Assert.False(config.StaticFiles.Enabled); Assert.Null(config.NavigationFallback.Path); + Assert.Empty(config.NavigationFallback.Exclude); + Assert.Empty(config.Headers); + Assert.Empty(config.Redirects); Assert.False(config.Telemetry.UnsafeAcceptAnyCertificate); } @@ -65,6 +162,20 @@ public void Legacy_EnableStaticFiles_ImpliesFallback() Assert.Equal("/index.html", config.NavigationFallback.Path); } + [Fact] + public void Legacy_EnableStaticFiles_ImpliesFallback_WhenOnlyFallbackExclusionsConfigured() + { + var config = Bind(new() + { + ["YARP_ENABLE_STATIC_FILES"] = "true", + ["NavigationFallback:Exclude:0:Path"] = "/api/{**catch-all}" + }); + + Assert.Equal("/index.html", config.NavigationFallback.Path); + var exclusion = Assert.Single(config.NavigationFallback.Exclude); + Assert.Equal("/api/{**catch-all}", exclusion.Path); + } + [Fact] public void Legacy_DisableSpaFallback() { @@ -87,15 +198,14 @@ public void Legacy_UnsafeCert() // Precedence: new config wins over legacy [Fact] - public void Precedence_NewConfigSection_PreventsLegacyFallback() + public void Precedence_ExplicitFallbackPathWinsOverLegacyFallback() { - // When NavigationFallback section exists explicitly, legacy - // YARP_ENABLE_STATIC_FILES doesn't auto-set fallback path var config = Bind(new() { ["YARP_ENABLE_STATIC_FILES"] = "true", ["NavigationFallback:Path"] = "/custom.html" }); + Assert.Equal("/custom.html", config.NavigationFallback.Path); } diff --git a/test/Application.Tests/wwwroot/404.html b/test/Application.Tests/wwwroot/404.html new file mode 100644 index 000000000..3fda41b18 --- /dev/null +++ b/test/Application.Tests/wwwroot/404.html @@ -0,0 +1,2 @@ + +

Custom 404

diff --git a/test/Application.Tests/wwwroot/server-error.html b/test/Application.Tests/wwwroot/server-error.html new file mode 100644 index 000000000..0c2186029 --- /dev/null +++ b/test/Application.Tests/wwwroot/server-error.html @@ -0,0 +1,2 @@ + +

Server error