diff --git a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md new file mode 100644 index 0000000000..4f884d5443 --- /dev/null +++ b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md @@ -0,0 +1,107 @@ +# SEO-Friendly Localized URLs in ABP with a Single Line of Configuration + +ABP has always supported language switching via the `?culture=en` query string and the culture cookie. That works fine for most applications — but it has a limitation that shows up quickly once SEO or link-sharing matters. + +Consider a book-store app where users browse in their language: + +- A Spanish user shares a product link. The recipient opens it in English because the cookie on *their* machine says `en`. +- Search engines crawl the same URL in every language, making it impossible to create separate sitemaps per locale. +- A user bookmarks `/Books/Detail?id=42&culture=es`. After a cookie reset, the `?culture=` parameter is missing from the bookmark — the page loads in the wrong language. + +Embedding the culture in the URL path — `/es/books`, `/zh-Hans/about` — solves all three. Each language has its own stable URL, readable by humans and index-friendly for search engines. + +ABP supports this out of the box. You opt in with a single configuration property, and the framework takes care of routing, URL generation, menu links, and language switching automatically. + +## Enabling URL-Based Localization + +In your ABP module class, add: + +```csharp +Configure(options => +{ + options.UseRouteBasedCulture = true; +}); +``` + +That is the only change you need to make. The ABP application template already uses the correct middleware order — `UseAbpRequestLocalization()` comes after `UseRouting()` — so the default pipeline works as-is: + +```csharp +app.UseRouting(); +app.UseAbpRequestLocalization(); // already in this position by default +app.UseAuthorization(); +app.UseConfiguredEndpoints(); +``` + +> This order matters because culture detection from route data is only possible after routing has assigned route values. If you have manually rearranged your middleware pipeline, make sure this order is preserved. + +## What ABP Registers Automatically + +Setting `UseRouteBasedCulture = true` triggers a cascade of automatic registrations. It is worth knowing what they are, because understanding them helps when you need to troubleshoot or extend the feature. + +**Route registration.** ABP inserts a `{culture}/{controller}/{action}/{id?}` conventional route *before* the default route. A matching route constraint (`^[a-zA-Z]{2,8}(-[a-zA-Z0-9]{1,8})*$`) ensures only valid IETF BCP 47 language tags are accepted, so a URL like `/enterprise/products` is not mistaken for a culture-prefixed route. + +**Razor Pages convention.** `AbpCultureRoutePagesConvention` adds a `{culture}/...` selector to every Razor Page route model at startup. This is what makes `/zh-Hans/Books` match the `Books/Index.cshtml` page. + +**URL helper factory.** ABP replaces `IUrlHelperFactory` with `AbpCultureRouteUrlHelperFactory`. When the current request has a `{culture}` route value, every call to `Url.Page()` or `Url.Action()` automatically receives the culture as an explicit route value — no code changes needed in your views or pages. + +**Menu URL provider.** `AbpCultureMenuItemUrlProvider` implements the new `IMenuItemUrlProvider` extension point. When the menu is built for a request, all local menu item URLs get the culture prefix prepended automatically. Themes and menu contributors remain completely untouched. + +## URL Generation Just Works + +In a Razor Page or view running under a culture-prefixed URL (say, `/zh-Hans/Books`), you do not need to pass a `culture` parameter anywhere: + +```cshtml + +@Url.Page("/Books/Detail", new { id = book.Id }) +@* Generates: /zh-Hans/Books/Detail?id=42 *@ + +@Url.Action("About", "Home") +@* Generates: /zh-Hans/Home/About *@ +``` + +`AbpCultureRouteUrlHelperFactory` injects the ambient culture value into every URL generation call. If you explicitly pass a different `culture` value, that takes precedence — so cross-language links are also straightforward: + +```cshtml +@Url.Page("/Books/Index", new { culture = "tr" }) +@* Generates: /tr/Books *@ +``` + +## Language Switching + +The built-in ABP language switcher (`/Abp/Languages/Switch`) already works with route-based culture. When a user switches language, the controller reads the current culture from the **request cookie** and rewrites the `{culture}` segment in the `returnUrl`. + +| Current URL | Switch to | Redirect to | +|---|---|---| +| `/tr/books` | `en` | `/en/books` | +| `/zh-Hans/about` | `en` | `/en/about` | +| `/tenant-a/zh-Hans/about` | `en` | `/tenant-a/en/about` | +| `/books?culture=tr&ui-culture=tr` | `en` | `/books?culture=en&ui-culture=en` | + +Reading from the request cookie (rather than `CultureInfo.CurrentCulture`) is important: the switch URL itself carries `?culture=zh-Hans` as a query parameter, which the ASP.NET Core `QueryStringRequestCultureProvider` would otherwise interpret first, overwriting the "current" culture before the controller runs. + +No theme changes, no language switcher changes — the existing UI component just works. + +## Multi-Tenancy + +URL-based localization is fully compatible with ABP's multi-tenant routing. The culture route is handled as a separate routing layer from the tenant. Language switching explicitly supports tenant-prefixed URLs, so `/tenant-a/zh-Hans/About → /tenant-a/en/About` works without any additional configuration. + +> For details on combining tenant routing with culture routing, see the [Multi-Tenancy](https://docs.abp.io/en/abp/latest/Multi-Tenancy) documentation. + +## Summary + +To add SEO-friendly localized URL paths to your ABP application: + +1. Set `options.UseRouteBasedCulture = true` in your module. +2. Ensure `UseAbpRequestLocalization()` comes after `UseRouting()` in the pipeline. + +ABP automatically registers the culture route, adds `{culture}/...` selectors to all Razor Pages, rewrites URLs generated by `Url.Page()` and `Url.Action()`, updates navigation menus, and handles language switching — all without any changes to themes, menu contributors, or views. + +A runnable sample demonstrating this feature is available at [abp-samples/UrlBasedLocalization](https://github.com/abpframework/abp-samples/tree/master/UrlBasedLocalization). It includes English, Turkish, French, and Simplified Chinese, and is the quickest way to see the feature in action before integrating it into your own project. + +## References + +- [URL-Based Localization — ABP Documentation](https://docs.abp.io/en/abp/latest/URL-Based-Localization) +- [Localization — ABP Documentation](https://docs.abp.io/en/abp/latest/Localization) +- [abp-samples/UrlBasedLocalization — GitHub](https://github.com/abpframework/abp-samples/tree/master/UrlBasedLocalization) +- [Request Localization in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/select-language-culture) +- [IETF BCP 47 Language Tags](https://www.rfc-editor.org/info/bcp47) diff --git a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/cover.png b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/cover.png new file mode 100644 index 0000000000..6a044aad99 Binary files /dev/null and b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/cover.png differ diff --git a/docs/en/framework/fundamentals/index.md b/docs/en/framework/fundamentals/index.md index 7ffa99fd89..5becda669b 100644 --- a/docs/en/framework/fundamentals/index.md +++ b/docs/en/framework/fundamentals/index.md @@ -17,6 +17,7 @@ The following documents explains the fundamental building blocks to create ABP s * [Dependency Injection](./dependency-injection.md) * [Exception Handling](./exception-handling.md) * [Localization](./localization.md) +* [URL-Based Localization](./url-based-localization.md) * [Logging](./logging.md) * [Object Extensions](./object-extensions.md) * [Options](./options.md) diff --git a/docs/en/framework/fundamentals/localization.md b/docs/en/framework/fundamentals/localization.md index 7a4fcf24ac..562446a892 100644 --- a/docs/en/framework/fundamentals/localization.md +++ b/docs/en/framework/fundamentals/localization.md @@ -294,6 +294,10 @@ Configure(options => }); ``` +## URL-Based Localization + +ABP supports embedding the culture code directly in the URL path (e.g. `/en/products`, `/zh-Hans/about`), which is useful for SEO-friendly and shareable localized URLs. See the [URL-Based Localization](./url-based-localization.md) document for details. + ## The Client Side See the following documents to learn how to reuse the same localization texts in the JavaScript side; diff --git a/docs/en/framework/fundamentals/url-based-localization.md b/docs/en/framework/fundamentals/url-based-localization.md new file mode 100644 index 0000000000..db8461b160 --- /dev/null +++ b/docs/en/framework/fundamentals/url-based-localization.md @@ -0,0 +1,121 @@ +````json +//[doc-seo] +{ + "Description": "Learn how to use ABP's URL-based localization to embed culture in the URL path, enabling SEO-friendly and shareable localized URLs." +} +```` + +# URL-Based Localization + +ABP supports embedding the current culture directly in the URL path, for example `/tr/products` or `/en/about`. This approach is widely used by documentation sites, e-commerce platforms, and any site that needs SEO-friendly, shareable localized URLs. + +By default, ABP detects language from QueryString (`?culture=tr`), Cookie, and `Accept-Language` header. URL path detection is **opt-in** and fully backward-compatible. + +## Enabling URL-Based Localization + +Configure the `AbpRequestLocalizationOptions` in your [module class](../architecture/modularity/basics.md): + +````csharp +Configure(options => +{ + options.UseRouteBasedCulture = true; +}); +```` + +That's all you need. The framework automatically handles the rest. + +> You also need to ensure that `UseAbpRequestLocalization()` is called **after** `UseRouting()` in your middleware pipeline. See the [Middleware Order](#middleware-order) section below. + +## What Happens Automatically + +When you set `UseRouteBasedCulture` to `true`, ABP automatically registers the following: + +* **`RouteDataRequestCultureProvider`** — Reads `{culture}` from route data (highest priority provider). +* **`{culture}/{controller}/{action}` route** — A conventional route for MVC controllers. +* **`AbpCultureRoutePagesConvention`** — An `IPageRouteModelConvention` that adds `{culture}/...` route selectors to all Razor Pages. +* **`AbpCultureRouteUrlHelperFactory`** — Replaces the default `IUrlHelperFactory` to auto-inject culture into `Url.Page()` and `Url.Action()` calls. +* **`AbpCultureMenuItemUrlProvider`** — Prepends the culture prefix to navigation menu item URLs. + +You do not need to configure these individually. + +## Middleware Order + +URL-based localization requires `UseAbpRequestLocalization()` to be called **after** `UseRouting()`: + +````csharp +app.MapAbpStaticAssets(); +app.UseRouting(); +app.UseAbpRequestLocalization(); // Must be after UseRouting() +app.UseAuthorization(); +app.UseConfiguredEndpoints(); +```` + +> If you do not enable `UseRouteBasedCulture`, the middleware order does not matter and your existing application continues to work as before. + +## URL Generation + +When a request has a `{culture}` route value, all URL generation methods automatically include the culture prefix: + +````csharp +// In a Razor Page — culture is auto-injected, no manual parameter needed +@Url.Page("/About") // Generates: /zh-Hans/About +@Url.Action("About", "Home") // Generates: /zh-Hans/Home/About +```` + +This works because `AbpCultureRouteUrlHelperFactory` replaces the default `IUrlHelperFactory` and injects the current `{culture}` route value into all URL generation calls. + +Menu items registered via `IMenuContributor` also automatically get the culture prefix. No changes are needed in your menu contributors or theme. + +## Language Switching + +ABP's built-in language switcher (the `/Abp/Languages/Switch` action) automatically replaces the culture segment in the `returnUrl`. The controller reads `CultureInfo.CurrentCulture` to identify the current culture and replaces it with the new one: + +| Before switching | After switching to English | +|---|---| +| `/tr/products` | `/en/products` | +| `/tenant-a/zh-Hans/about` | `/tenant-a/en/about` | +| `/home?culture=tr&ui-culture=tr` | `/home?culture=en&ui-culture=en` | +| `/about` (no prefix) | `/about` (unchanged) | + +No changes are needed in any theme or language switcher component. + +## Blazor Server + +Blazor Server uses SignalR (WebSocket), which does not re-run the HTTP middleware pipeline after the initial connection. ABP automatically persists the detected URL culture to a **Cookie** on the first request, so the entire Blazor circuit uses the correct language. + +No additional configuration is needed beyond `UseRouteBasedCulture = true` and the correct middleware order. + +## Blazor WebAssembly + +The server project handles culture detection via routing. The WebAssembly client reads the culture from the server's application configuration API, which already reflects the URL-based culture. + +No code changes are required in the WASM project. + +## Multi-Tenancy Compatibility + +URL-based localization is fully compatible with [multi-tenancy URL routing](../architecture/multi-tenancy/index.md). The culture route is registered as a conventional route `{culture}/{controller}/{action}`. If your application uses tenant routing (e.g., `/{tenant}/...`), the tenant middleware strips the tenant segment before routing, and the culture segment is handled separately. + +Language switching also supports tenant-prefixed URLs. For example, `/tenant-a/zh-Hans/About` correctly switches to `/tenant-a/en/About`. + +## API Routes + +Routes like `/api/products` have no `{culture}` segment, so `RouteDataRequestCultureProvider` returns `null` and falls through to the next provider (Cookie → `Accept-Language` → default). API routes are completely unaffected. + +## FAQ + +### What happens with an invalid culture code in the URL? + +If `/xyz1234/page` is requested and `xyz1234` is not a valid culture, `RequestLocalizationMiddleware` ignores it and falls through to the default culture. No error is thrown. + +### Can I mix URL-based and QueryString-based culture detection? + +Yes. All providers work together in priority order: + +1. `RouteDataRequestCultureProvider` (URL path — highest priority when enabled) +2. `QueryStringRequestCultureProvider` +3. `CookieRequestCultureProvider` +4. `AcceptLanguageHeaderRequestCultureProvider` + +### Should I keep both localized and non-localized routes? + +Yes. ABP automatically registers both `{culture}/{controller}/{action}` and `{controller}/{action}` routes. The second route handles direct navigation to `/` and any controller action that doesn't have a culture prefix. diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs index ad8d8c1c28..e0d8cc5d4d 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs @@ -16,6 +16,8 @@ using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.DataAnnotations; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.RequestLocalization; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Localization; @@ -212,6 +214,41 @@ public class AbpAspNetCoreMvcModule : AbpModule { preConfigureActions.Configure(options); }); + + ConfigureRouteBasedCulture(context); + } + + protected virtual void ConfigureRouteBasedCulture(ServiceConfigurationContext context) + { + context.Services + .AddOptions() + .PostConfigure>((routerOptions, abpLocOptions) => + { + if (abpLocOptions.Value.UseRouteBasedCulture) + { + routerOptions.EndpointConfigureActions.Insert(0, endpointContext => + { + endpointContext.Endpoints.MapControllerRoute( + "AbpCultureRoute", + AbpCultureRoutePagesConvention.CultureRouteTemplate + "/{controller=Home}/{action=Index}/{id?}"); + }); + } + }); + + context.Services + .AddOptions() + .PostConfigure>((pagesOptions, abpLocOptions) => + { + if (abpLocOptions.Value.UseRouteBasedCulture && + !pagesOptions.Conventions.OfType().Any()) + { + pagesOptions.Conventions.Add(new AbpCultureRoutePagesConvention()); + } + }); + + context.Services.TryAddSingleton(); + context.Services.Replace(ServiceDescriptor.Singleton()); + context.Services.AddTransient(); } public override void OnApplicationInitialization(ApplicationInitializationContext context) diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs index 688392f3f1..b8b4e6a8ea 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Microsoft.AspNetCore.Routing; using Volo.Abp.DependencyInjection; namespace Volo.Abp.AspNetCore.Mvc.Localization; @@ -9,23 +10,39 @@ public class AbpAspNetCoreMvcQueryStringCultureReplacement : IQueryStringCulture { public virtual Task ReplaceAsync(QueryStringCultureReplacementContext context) { - if (!string.IsNullOrWhiteSpace(context.ReturnUrl)) + if (string.IsNullOrWhiteSpace(context.ReturnUrl)) { - if (context.ReturnUrl.Contains("culture=", StringComparison.OrdinalIgnoreCase) && - context.ReturnUrl.Contains("ui-Culture=", StringComparison.OrdinalIgnoreCase)) - { - context.ReturnUrl = Regex.Replace( - context.ReturnUrl, - "culture=[A-Za-z-]+", - $"culture={context.RequestCulture.Culture}", - RegexOptions.Compiled | RegexOptions.IgnoreCase); + return Task.CompletedTask; + } + + var currentCulture = context.CurrentCulture + ?? context.HttpContext.GetRouteValue("culture")?.ToString(); + + if (!string.IsNullOrEmpty(currentCulture)) + { + var escapedCulture = Regex.Escape(currentCulture); + var pattern = $"/{escapedCulture}(?=/|$|\\?|#)"; + context.ReturnUrl = Regex.Replace( + context.ReturnUrl, + pattern, + "/" + context.RequestCulture.Culture.Name, + RegexOptions.IgnoreCase); + } + + if (context.ReturnUrl.Contains("culture=", StringComparison.OrdinalIgnoreCase) && + context.ReturnUrl.Contains("ui-Culture=", StringComparison.OrdinalIgnoreCase)) + { + context.ReturnUrl = Regex.Replace( + context.ReturnUrl, + "culture=[A-Za-z-]+", + $"culture={context.RequestCulture.Culture}", + RegexOptions.Compiled | RegexOptions.IgnoreCase); - context.ReturnUrl = Regex.Replace( - context.ReturnUrl, - "ui-culture=[A-Za-z-]+", - $"ui-culture={context.RequestCulture.UICulture}", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - } + context.ReturnUrl = Regex.Replace( + context.ReturnUrl, + "ui-culture=[A-Za-z-]+", + $"ui-culture={context.RequestCulture.UICulture}", + RegexOptions.Compiled | RegexOptions.IgnoreCase); } return Task.CompletedTask; diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper.cs new file mode 100644 index 0000000000..35c8dedeef --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +/// +/// Wraps an to automatically inject the culture route value +/// into all URL generation calls. +/// +public class AbpCultureAwareUrlHelper : IUrlHelper +{ + protected IUrlHelper Inner { get; } + protected string Culture { get; } + + public AbpCultureAwareUrlHelper(IUrlHelper inner, string culture) + { + Inner = inner; + Culture = culture; + } + + public ActionContext ActionContext => Inner.ActionContext; + + public virtual string? Action(UrlActionContext actionContext) + { + var values = new RouteValueDictionary(actionContext.Values); + values.TryAdd("culture", Culture); + + return Inner.Action(new UrlActionContext + { + Action = actionContext.Action, + Controller = actionContext.Controller, + Values = values, + Protocol = actionContext.Protocol, + Host = actionContext.Host, + Fragment = actionContext.Fragment, + }); + } + + public virtual string? Content(string? contentPath) + { + return Inner.Content(contentPath); + } + + public virtual bool IsLocalUrl(string? url) + { + return Inner.IsLocalUrl(url); + } + + public virtual string? Link(string? routeName, object? values) + { + var rvd = new RouteValueDictionary(values); + rvd.TryAdd("culture", Culture); + return Inner.Link(routeName, rvd); + } + + public virtual string? RouteUrl(UrlRouteContext routeContext) + { + var values = new RouteValueDictionary(routeContext.Values); + values.TryAdd("culture", Culture); + + return Inner.RouteUrl(new UrlRouteContext + { + RouteName = routeContext.RouteName, + Values = values, + Protocol = routeContext.Protocol, + Host = routeContext.Host, + Fragment = routeContext.Fragment, + }); + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs new file mode 100644 index 0000000000..3190d11664 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs @@ -0,0 +1,59 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RequestLocalization; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; +using Volo.Abp.UI.Navigation; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +/// +/// Prepends the culture route prefix to all local menu item URLs +/// when the current request has a {culture} route value. +/// Only activates when is true. +/// +public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider +{ + protected IHttpContextAccessor HttpContextAccessor { get; } + protected IOptions LocalizationOptions { get; } + + public AbpCultureMenuItemUrlProvider( + IHttpContextAccessor httpContextAccessor, + IOptions localizationOptions) + { + HttpContextAccessor = httpContextAccessor; + LocalizationOptions = localizationOptions; + } + + public virtual Task HandleAsync(MenuItemUrlProviderContext context) + { + if (!LocalizationOptions.Value.UseRouteBasedCulture) + { + return Task.CompletedTask; + } + + var culture = HttpContextAccessor.HttpContext?.GetRouteValue("culture")?.ToString(); + if (string.IsNullOrEmpty(culture)) + { + return Task.CompletedTask; + } + + var prefix = "/" + culture; + PrependCulturePrefix(context.Menu, prefix); + + return Task.CompletedTask; + } + + protected virtual void PrependCulturePrefix(IHasMenuItems menuWithItems, string prefix) + { + foreach (var item in menuWithItems.Items) + { + if (item.Url != null && item.Url.StartsWith('/')) + { + item.Url = prefix + item.Url; + } + + PrependCulturePrefix(item, prefix); + } + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRoutePagesConvention.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRoutePagesConvention.cs new file mode 100644 index 0000000000..8ccbf6ab83 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRoutePagesConvention.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc.ApplicationModels; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +/// +/// Adds a {culture}-prefixed route selector to every Razor Page. +/// Automatically registered when UseRouteBasedCulture is true. +/// +public class AbpCultureRoutePagesConvention : IPageRouteModelConvention +{ + /// + /// Route parameter template for culture with a regex constraint matching IETF BCP 47 language tags + /// (e.g. "en", "zh-Hans", "sr-Latn-RS"). The double braces are required by the route template + /// parser to represent literal { } characters inside the regex constraint. + /// + internal const string CultureRouteTemplate = "{culture:regex(^[a-zA-Z]{{2,8}}(-[a-zA-Z0-9]{{1,8}})*$)}"; + + public void Apply(PageRouteModel model) + { + var selectorsToAdd = new List(); + + foreach (var selector in model.Selectors.ToList()) + { + var originalTemplate = selector.AttributeRouteModel?.Template?.TrimStart('/'); + if (originalTemplate == null) + { + continue; + } + + selectorsToAdd.Add(new SelectorModel + { + AttributeRouteModel = new AttributeRouteModel + { + Template = AttributeRouteModel.CombineTemplates(CultureRouteTemplate, originalTemplate), + Order = -1 + } + }); + } + + foreach (var selector in selectorsToAdd) + { + model.Selectors.Add(selector); + } + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteUrlHelperFactory.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteUrlHelperFactory.cs new file mode 100644 index 0000000000..7be4456677 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteUrlHelperFactory.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.RequestLocalization; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +/// +/// Wraps the default to automatically inject the culture +/// route value into all URL generation calls when the current request has a {culture} route value. +/// Only activates when is true. +/// +public class AbpCultureRouteUrlHelperFactory : IUrlHelperFactory +{ + protected UrlHelperFactory Inner { get; } + protected IOptions LocalizationOptions { get; } + + public AbpCultureRouteUrlHelperFactory( + UrlHelperFactory inner, + IOptions localizationOptions) + { + Inner = inner; + LocalizationOptions = localizationOptions; + } + + public virtual IUrlHelper GetUrlHelper(ActionContext context) + { + var urlHelper = Inner.GetUrlHelper(context); + + if (!LocalizationOptions.Value.UseRouteBasedCulture) + { + return urlHelper; + } + + if (context.RouteData.Values.TryGetValue("culture", out var culture) && + culture != null) + { + return CreateCultureAwareUrlHelper(urlHelper, culture.ToString()!); + } + + return urlHelper; + } + + protected virtual AbpCultureAwareUrlHelper CreateCultureAwareUrlHelper(IUrlHelper urlHelper, string culture) + { + return new AbpCultureAwareUrlHelper(urlHelper, culture); + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController.cs index 73c93851ff..dc5dd3ccf6 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController.cs @@ -1,7 +1,8 @@ -using Microsoft.AspNetCore.Localization; -using Microsoft.AspNetCore.Mvc; using System; +using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Localization; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RequestLocalization; using Volo.Abp.Auditing; using Volo.Abp.Localization; @@ -42,7 +43,12 @@ public class AbpLanguagesController : AbpController HttpContext.Items[AbpRequestLocalizationMiddleware.HttpContextItemName] = true; - var context = new QueryStringCultureReplacementContext(HttpContext, new RequestCulture(culture, uiCulture), returnUrl); + var context = new QueryStringCultureReplacementContext( + HttpContext, + new RequestCulture(culture, uiCulture), + returnUrl, + GetCurrentCultureFromRequestCookie()); + await QueryStringCultureReplacement.ReplaceAsync(context); if (!string.IsNullOrWhiteSpace(context.ReturnUrl)) @@ -53,6 +59,18 @@ public class AbpLanguagesController : AbpController return Redirect("~/"); } + protected virtual string? GetCurrentCultureFromRequestCookie() + { + var cookieValue = HttpContext.Request.Cookies[CookieRequestCultureProvider.DefaultCookieName]; + if (cookieValue == null) + { + return null; + } + + var result = CookieRequestCultureProvider.ParseCookieValue(cookieValue); + return result?.Cultures.FirstOrDefault().Value; + } + protected virtual string GetRedirectUrl(string returnUrl) { if (returnUrl.IsNullOrEmpty()) diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/QueryStringCultureReplacementContext.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/QueryStringCultureReplacementContext.cs index b70c24fb95..15866f54bc 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/QueryStringCultureReplacementContext.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/QueryStringCultureReplacementContext.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Localization; namespace Volo.Abp.AspNetCore.Mvc.Localization; @@ -11,10 +11,17 @@ public class QueryStringCultureReplacementContext public string ReturnUrl { get; set; } - public QueryStringCultureReplacementContext(HttpContext httpContext, RequestCulture requestCulture, string returnUrl) + public string? CurrentCulture { get; } + + public QueryStringCultureReplacementContext( + HttpContext httpContext, + RequestCulture requestCulture, + string returnUrl, + string? currentCulture = null) { HttpContext = httpContext; RequestCulture = requestCulture; ReturnUrl = returnUrl; + CurrentCulture = currentCulture; } } diff --git a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs index cb531762ba..07451c1cbb 100644 --- a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs +++ b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Localization; +using Microsoft.AspNetCore.Localization.Routing; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Volo.Abp.AspNetCore.Middleware; @@ -39,7 +40,8 @@ public class AbpRequestLocalizationMiddleware : AbpMiddlewareBase, ITransientDep if (context.Items[HttpContextItemName] == null) { var requestCultureFeature = context.Features.Get(); - if (requestCultureFeature?.Provider is QueryStringRequestCultureProvider) + if (requestCultureFeature?.Provider is QueryStringRequestCultureProvider + or RouteDataRequestCultureProvider) { AbpRequestCultureCookieHelper.SetCultureCookie( context, diff --git a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationOptions.cs b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationOptions.cs index bb83ce9a53..55394b7d17 100644 --- a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationOptions.cs +++ b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationOptions.cs @@ -9,6 +9,12 @@ public class AbpRequestLocalizationOptions { public List> RequestLocalizationOptionConfigurators { get; } + /// + /// Enables culture detection from route data (e.g. /{culture}/page). + /// Default value: false. + /// + public bool UseRouteBasedCulture { get; set; } + public AbpRequestLocalizationOptions() { RequestLocalizationOptionConfigurators = new List>(); diff --git a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/DefaultAbpRequestLocalizationOptionsProvider.cs b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/DefaultAbpRequestLocalizationOptionsProvider.cs index cf9f128852..37343733ae 100644 --- a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/DefaultAbpRequestLocalizationOptionsProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/DefaultAbpRequestLocalizationOptionsProvider.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Localization; +using Microsoft.AspNetCore.Localization.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Volo.Abp.DependencyInjection; @@ -70,9 +71,16 @@ public class DefaultAbpRequestLocalizationOptionsProvider : .ToArray() }; - foreach (var configurator in serviceScope.ServiceProvider - .GetRequiredService>() - .Value.RequestLocalizationOptionConfigurators) + var abpRequestLocalizationOptions = serviceScope.ServiceProvider + .GetRequiredService>() + .Value; + + if (abpRequestLocalizationOptions.UseRouteBasedCulture) + { + options.RequestCultureProviders.Insert(0, new RouteDataRequestCultureProvider()); + } + + foreach (var configurator in abpRequestLocalizationOptions.RequestLocalizationOptionConfigurators) { await configurator(serviceScope.ServiceProvider, options); } diff --git a/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemUrlProvider.cs b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemUrlProvider.cs new file mode 100644 index 0000000000..faae8c2367 --- /dev/null +++ b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemUrlProvider.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; + +namespace Volo.Abp.UI.Navigation; + +/// +/// Provides a way to modify menu item URLs after the menu is fully configured. +/// Implementations can transform URLs based on the current request context +/// (e.g. adding a culture prefix for URL-based localization). +/// +public interface IMenuItemUrlProvider +{ + Task HandleAsync(MenuItemUrlProviderContext context); +} diff --git a/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemUrlProviderContext.cs b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemUrlProviderContext.cs new file mode 100644 index 0000000000..55f9d178da --- /dev/null +++ b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemUrlProviderContext.cs @@ -0,0 +1,11 @@ +namespace Volo.Abp.UI.Navigation; + +public class MenuItemUrlProviderContext +{ + public ApplicationMenu Menu { get; } + + public MenuItemUrlProviderContext(ApplicationMenu menu) + { + Menu = menu; + } +} diff --git a/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs index 6334ea9918..f1d8ae22d7 100644 --- a/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs +++ b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs @@ -92,6 +92,12 @@ public class MenuManager : IMenuManager, ITransientDependency await CheckPermissionsAsync(scope.ServiceProvider, menu); } + + var urlProviderContext = new MenuItemUrlProviderContext(menu); + foreach (var urlProvider in scope.ServiceProvider.GetServices()) + { + await urlProvider.HandleAsync(urlProviderContext); + } } NormalizeMenu(menu); diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper_Tests.cs new file mode 100644 index 0000000000..d761d85bf8 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper_Tests.cs @@ -0,0 +1,137 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.RequestLocalization; +using Microsoft.AspNetCore.Routing; +using NSubstitute; +using Shouldly; +using Volo.Abp.AspNetCore.Mvc.Localization; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +public class AbpCultureAwareUrlHelper_Tests +{ + [Fact] + public void Action_Should_Inject_Culture() + { + var inner = Substitute.For(); + inner.Action(Arg.Any()).Returns(callInfo => + { + var ctx = callInfo.Arg(); + var values = new RouteValueDictionary(ctx.Values); + return values.ContainsKey("culture") ? $"/{values["culture"]}/{ctx.Controller}/{ctx.Action}" : $"/{ctx.Controller}/{ctx.Action}"; + }); + + var helper = new AbpCultureAwareUrlHelper(inner, "zh-Hans"); + var result = helper.Action(new UrlActionContext { Controller = "Home", Action = "Index" }); + + result.ShouldContain("zh-Hans"); + } + + [Fact] + public void Action_Should_Not_Override_Explicit_Culture() + { + var inner = Substitute.For(); + inner.Action(Arg.Any()).Returns(callInfo => + { + var ctx = callInfo.Arg(); + var values = new RouteValueDictionary(ctx.Values); + return $"/{values["culture"]}/Home/Index"; + }); + + var helper = new AbpCultureAwareUrlHelper(inner, "zh-Hans"); + var result = helper.Action(new UrlActionContext + { + Controller = "Home", + Action = "Index", + Values = new { culture = "en" } + }); + + // Explicit "en" should not be overridden by "zh-Hans" + result.ShouldBe("/en/Home/Index"); + } + + [Fact] + public void RouteUrl_Should_Inject_Culture() + { + var inner = Substitute.For(); + inner.RouteUrl(Arg.Any()).Returns(callInfo => + { + var ctx = callInfo.Arg(); + var values = new RouteValueDictionary(ctx.Values); + return values.ContainsKey("culture") ? $"/{values["culture"]}/page" : "/page"; + }); + + var helper = new AbpCultureAwareUrlHelper(inner, "tr"); + var result = helper.RouteUrl(new UrlRouteContext()); + + result.ShouldBe("/tr/page"); + } + + [Fact] + public void Content_Should_Pass_Through() + { + var inner = Substitute.For(); + inner.Content("~/test").Returns("/test"); + + var helper = new AbpCultureAwareUrlHelper(inner, "en"); + helper.Content("~/test").ShouldBe("/test"); + } + + [Fact] + public void IsLocalUrl_Should_Pass_Through() + { + var inner = Substitute.For(); + inner.IsLocalUrl("/test").Returns(true); + + var helper = new AbpCultureAwareUrlHelper(inner, "en"); + helper.IsLocalUrl("/test").ShouldBeTrue(); + } + + [Fact] + public void Factory_Should_Return_CultureAwareHelper_When_Culture_In_Route() + { + var factory = CreateFactory(useRouteBasedCulture: true); + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues["culture"] = "tr"; + var actionContext = new ActionContext(httpContext, new RouteData(httpContext.Request.RouteValues), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor()); + + var urlHelper = factory.GetUrlHelper(actionContext); + + urlHelper.ShouldBeOfType(); + } + + [Fact] + public void Factory_Should_Return_Default_Helper_When_No_Culture() + { + var factory = CreateFactory(useRouteBasedCulture: true); + var httpContext = new DefaultHttpContext(); + var actionContext = new ActionContext(httpContext, new RouteData(), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor()); + + var urlHelper = factory.GetUrlHelper(actionContext); + + urlHelper.ShouldNotBeOfType(); + } + + [Fact] + public void Factory_Should_Return_Default_Helper_When_RouteBasedCulture_Disabled() + { + var factory = CreateFactory(useRouteBasedCulture: false); + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues["culture"] = "tr"; + var actionContext = new ActionContext(httpContext, new RouteData(httpContext.Request.RouteValues), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor()); + + // Even with culture in route, should not wrap when the feature is disabled + var urlHelper = factory.GetUrlHelper(actionContext); + + urlHelper.ShouldNotBeOfType(); + } + + private static AbpCultureRouteUrlHelperFactory CreateFactory(bool useRouteBasedCulture) + { + return new AbpCultureRouteUrlHelperFactory( + new UrlHelperFactory(), + Microsoft.Extensions.Options.Options.Create(new AbpRequestLocalizationOptions { UseRouteBasedCulture = useRouteBasedCulture })); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController_Tests.cs new file mode 100644 index 0000000000..491577c43e --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController_Tests.cs @@ -0,0 +1,123 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Localization; +using Shouldly; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +public class AbpLanguagesController_Tests : AspNetCoreMvcTestBase +{ + private const string SwitchUrl = "/Abp/Languages/Switch"; + + [Fact] + public async Task Should_Replace_Route_Culture_In_ReturnUrl_When_Cookie_Is_Set() + { + var response = await SendSwitchRequestAsync( + targetCulture: "zh-Hans", + returnUrl: "/en/Home/About", + currentCultureCookie: "en"); + + response.StatusCode.ShouldBe(HttpStatusCode.Found); + response.Headers.Location?.ToString().ShouldBe("/zh-Hans/Home/About"); + } + + [Fact] + public async Task Should_Replace_Route_Culture_When_Switching_Back() + { + var response = await SendSwitchRequestAsync( + targetCulture: "en", + returnUrl: "/zh-Hans/About", + currentCultureCookie: "zh-Hans"); + + response.StatusCode.ShouldBe(HttpStatusCode.Found); + response.Headers.Location?.ToString().ShouldBe("/en/About"); + } + + [Fact] + public async Task Should_Replace_Region_Culture_In_ReturnUrl() + { + var response = await SendSwitchRequestAsync( + targetCulture: "zh-Hans", + returnUrl: "/en-US/products", + currentCultureCookie: "en-US"); + + response.StatusCode.ShouldBe(HttpStatusCode.Found); + response.Headers.Location?.ToString().ShouldBe("/zh-Hans/products"); + } + + [Fact] + public async Task Should_Not_Replace_When_No_Cookie() + { + // No cookie — GetCurrentCultureFromRequestCookie returns null, no route replacement + var response = await SendSwitchRequestAsync( + targetCulture: "zh-Hans", + returnUrl: "/en/Home/About", + currentCultureCookie: null); + + response.StatusCode.ShouldBe(HttpStatusCode.Found); + response.Headers.Location?.ToString().ShouldBe("/en/Home/About"); + } + + [Fact] + public async Task Should_Redirect_To_Root_When_ReturnUrl_Is_Empty() + { + var response = await SendSwitchRequestAsync( + targetCulture: "zh-Hans", + returnUrl: "", + currentCultureCookie: "en"); + + response.StatusCode.ShouldBe(HttpStatusCode.Found); + response.Headers.Location?.ToString().ShouldStartWith("/"); + } + + [Fact] + public async Task Should_Not_Replace_Culture_Inside_Longer_Segment_Via_Http() + { + // "en" must not corrupt "/enterprise/products" + var response = await SendSwitchRequestAsync( + targetCulture: "zh-Hans", + returnUrl: "/enterprise/products", + currentCultureCookie: "en"); + + response.StatusCode.ShouldBe(HttpStatusCode.Found); + response.Headers.Location?.ToString().ShouldBe("/enterprise/products"); + } + + [Fact] + public async Task Should_Replace_Culture_After_Tenant_Segment() + { + // Multi-tenant URL: /tenant-a/zh-Hans/About → /tenant-a/en/About + var response = await SendSwitchRequestAsync( + targetCulture: "en", + returnUrl: "/tenant-a/zh-Hans/About", + currentCultureCookie: "zh-Hans"); + + response.StatusCode.ShouldBe(HttpStatusCode.Found); + response.Headers.Location?.ToString().ShouldBe("/tenant-a/en/About"); + } + + private async Task SendSwitchRequestAsync( + string targetCulture, + string returnUrl, + string? currentCultureCookie) + { + var url = $"{SwitchUrl}?culture={Uri.EscapeDataString(targetCulture)}" + + $"&uiCulture={Uri.EscapeDataString(targetCulture)}" + + $"&returnUrl={Uri.EscapeDataString(returnUrl)}"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + + if (currentCultureCookie != null) + { + var cookieValue = CookieRequestCultureProvider.MakeCookieValue( + new RequestCulture(currentCultureCookie, currentCultureCookie)); + request.Headers.Add("Cookie", + $"{CookieRequestCultureProvider.DefaultCookieName}={Uri.EscapeDataString(cookieValue)}"); + } + + return await Client.SendAsync(request); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs new file mode 100644 index 0000000000..5ee339e6ee --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs @@ -0,0 +1,227 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; +using Shouldly; +using Volo.Abp.AspNetCore.Mvc.Localization; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +public class LanguageSwitchRouteCultureReplacement_Tests +{ + private readonly AbpAspNetCoreMvcQueryStringCultureReplacement _replacement = new(); + + [Fact] + public async Task Should_Replace_Route_Prefix() + { + var context = CreateContext("tr", "en", "/tr/products"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/en/products"); + } + + [Fact] + public async Task Should_Replace_Region_Culture() + { + var context = CreateContext("en-US", "zh-Hans", "/en-US/about"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/zh-Hans/about"); + } + + [Fact] + public async Task Should_Replace_Culture_Only_Url() + { + var context = CreateContext("tr", "en", "/tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/en"); + } + + [Fact] + public async Task Should_Replace_Culture_With_Query_String() + { + var context = CreateContext("tr", "en", "/tr?returnUrl=/home"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/en?returnUrl=/home"); + } + + [Fact] + public async Task Should_Replace_Culture_After_Tenant() + { + var context = CreateContext("zh-Hans", "en", "/tenant-a/zh-Hans/About"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/tenant-a/en/About"); + } + + [Fact] + public async Task Should_Replace_Culture_Only_After_Tenant() + { + var context = CreateContext("tr", "en", "/tenant-a/tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/tenant-a/en"); + } + + [Fact] + public async Task Should_Replace_Via_RouteData_When_No_CurrentCulture() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues["culture"] = "tr"; + var context = new QueryStringCultureReplacementContext( + httpContext, new RequestCulture("en"), "/tr/products"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/en/products"); + } + + [Fact] + public async Task Should_Not_Replace_When_No_Culture_Source() + { + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en"), "/volosoft/products"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/volosoft/products"); + } + + [Fact] + public async Task Should_Not_Replace_Culture_Inside_Longer_Segment() + { + // "en" must not match inside "enterprise" + var context = CreateContext("en", "tr", "/enterprise/products"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/enterprise/products"); + } + + [Fact] + public async Task Should_Not_Replace_Culture_When_Culture_Is_Segment_Prefix() + { + // "fr" appears at the start of "fr-zone" but is not a complete segment + var context = CreateContext("fr", "en", "/fr-zone/about"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/fr-zone/about"); + } + + [Fact] + public async Task Should_Replace_Culture_Before_Fragment() + { + var context = CreateContext("en", "tr", "/en#section"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/tr#section"); + } + + [Fact] + public async Task Should_Replace_Culture_Before_Fragment_With_Path() + { + var context = CreateContext("en", "tr", "/en/about#top"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/tr/about#top"); + } + + [Fact] + public async Task Should_Replace_Query_String_Culture() + { + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en", "en"), "/home?culture=tr&ui-culture=tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/home?culture=en&ui-culture=en"); + } + + [Fact] + public async Task Should_Replace_Both_Route_And_Query_String() + { + var context = CreateContext("tr", "en", "/tr/home?culture=tr&ui-culture=tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/en/home?culture=en&ui-culture=en"); + } + + [Fact] + public async Task Should_Handle_Null_ReturnUrl() + { + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en"), null!); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBeNull(); + } + + [Fact] + public async Task Should_Handle_Empty_ReturnUrl() + { + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en"), ""); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe(""); + } + + [Fact] + public async Task Should_Not_Replace_When_CurrentCulture_Not_In_ReturnUrl() + { + // currentCulture is "fr" but returnUrl has no "/fr" segment + var context = CreateContext("fr", "en", "/about"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/about"); + } + + [Fact] + public async Task Should_Handle_Same_Culture_Switch() + { + // Switching to the same culture — no change + var context = CreateContext("en", "en", "/en/about"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/en/about"); + } + + [Fact] + public async Task Should_Replace_Case_Insensitive() + { + var context = CreateContext("zh-hans", "en", "/zh-Hans/about"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/en/about"); + } + + [Fact] + public async Task Should_Handle_Whitespace_ReturnUrl() + { + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en"), " "); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe(" "); + } + + [Fact] + public async Task Should_Prefer_CurrentCulture_Over_RouteData() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues["culture"] = "fr"; + var context = new QueryStringCultureReplacementContext( + httpContext, new RequestCulture("en"), "/tr/about", currentCulture: "tr"); + await _replacement.ReplaceAsync(context); + // Should use "tr" from CurrentCulture, not "fr" from RouteData + context.ReturnUrl.ShouldBe("/en/about"); + } + + [Fact] + public async Task Should_Only_Replace_Query_String_When_No_Route_Culture() + { + // No currentCulture, no RouteData — only query string replacement + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en", "en"), "/?culture=tr&ui-culture=tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/?culture=en&ui-culture=en"); + } + + [Fact] + public async Task Should_Not_Replace_Query_String_When_Only_One_Param() + { + // Only culture= without ui-culture= — should not replace + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en"), "/?culture=tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/?culture=tr"); + } + + private static QueryStringCultureReplacementContext CreateContext( + string currentCulture, string targetCulture, string returnUrl) + { + return new QueryStringCultureReplacementContext( + new DefaultHttpContext(), + new RequestCulture(targetCulture), + returnUrl, + currentCulture); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCultureTestModule.cs b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCultureTestModule.cs new file mode 100644 index 0000000000..31d6dc2bb6 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCultureTestModule.cs @@ -0,0 +1,53 @@ +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RequestLocalization; +using Microsoft.AspNetCore.Routing; +using Volo.Abp.Localization; +using Volo.Abp.Modularity; + +namespace Volo.Abp.AspNetCore.Localization; + +[DependsOn(typeof(AbpAspNetCoreTestModule))] +public class RouteBasedCultureTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.UseRouteBasedCulture = true; + }); + + Configure(options => + { + options.Languages.Add(new LanguageInfo("en", "en", "English")); + options.Languages.Add(new LanguageInfo("tr", "tr", "Türkçe")); + }); + } + + public override void OnApplicationInitialization(ApplicationInitializationContext context) + { + var app = context.GetApplicationBuilder(); + + app.UseRouting(); + app.UseAbpRequestLocalization(); + + app.UseEndpoints(endpoints => + { + endpoints.MapGet("{culture}/culture", async ctx => + { + await ctx.Response.WriteAsync(CultureInfo.CurrentCulture.Name); + }); + + endpoints.MapGet("culture", async ctx => + { + await ctx.Response.WriteAsync(CultureInfo.CurrentCulture.Name); + }); + + endpoints.MapGet("api/data", async ctx => + { + await ctx.Response.WriteAsync(CultureInfo.CurrentCulture.Name); + }); + }); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCulture_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCulture_Tests.cs new file mode 100644 index 0000000000..ac3be73275 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCulture_Tests.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Localization; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Xunit; + +namespace Volo.Abp.AspNetCore.Localization; + +public class RouteBasedCulture_Tests : IAsyncLifetime +{ + private WebApplication _app; + private HttpClient _client; + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Host.UseAutofac(); + await builder.AddApplicationAsync(); + _app = builder.Build(); + await _app.InitializeApplicationAsync(); + await _app.StartAsync(); + _client = ((IHost)_app).GetTestClient(); + } + + public async Task DisposeAsync() + { + _client?.Dispose(); + if (_app != null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + [Fact] + public async Task RouteBasedCulture_SetsCultureCorrectly() + { + var response = await _client!.GetStringAsync("/tr/culture"); + response.ShouldBe("tr"); + } + + [Fact] + public async Task RouteBasedCulture_SetsCookieOnResponse() + { + var response = await _client!.GetAsync("/tr/culture"); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + response.Headers.Contains("Set-Cookie").ShouldBeTrue(); + var cookieValue = string.Join(";", response.Headers.GetValues("Set-Cookie")); + cookieValue.ShouldContain(CookieRequestCultureProvider.DefaultCookieName); + cookieValue.ShouldContain("tr"); + } + + [Fact] + public async Task RouteBasedCulture_InvalidCultureCodeFallsThrough() + { + // "xyz1234" is not a valid culture - should fall through to the default culture "en" + var response = await _client!.GetStringAsync("/xyz1234/culture"); + response.ShouldBe("en"); + } + + [Fact] + public async Task RouteBasedCulture_ApiRoutesNotAffected() + { + // /api/data has no {culture} prefix route - falls through to the default culture "en" + var response = await _client!.GetStringAsync("/api/data"); + response.ShouldBe("en"); + } +}