@ -0,0 +1,72 @@ |
|||
# ABP.IO Platform 10.2 Final Has Been Released! |
|||
|
|||
We are glad to announce that [ABP](https://abp.io/) 10.2 stable version has been released. |
|||
|
|||
## What's New With Version 10.2? |
|||
|
|||
All the new features were explained in detail in the [10.2 RC Announcement Post](https://abp.io/community/announcements/announcing-abp-10-2-release-candidate-05zatjfq), so there is no need to review them again. You can check it out for more details. |
|||
|
|||
## Getting Started with 10.2 |
|||
|
|||
### How to Upgrade an Existing Solution |
|||
|
|||
You can upgrade your existing solutions with either ABP Studio or ABP CLI. In the following sections, both approaches are explained: |
|||
|
|||
### Upgrading via ABP Studio |
|||
|
|||
If you are already using the ABP Studio, you can upgrade it to the latest version. ABP Studio periodically checks for updates in the background, and when a new version of ABP Studio is available, you will be notified through a modal. Then, you can update it by confirming the opened modal. See [the documentation](https://abp.io/docs/latest/studio/installation#upgrading) for more info. |
|||
|
|||
After upgrading the ABP Studio, then you can open your solution in the application, and simply click the **Upgrade ABP Packages** action button to instantly upgrade your solution: |
|||
|
|||
 |
|||
|
|||
### Upgrading via ABP CLI |
|||
|
|||
Alternatively, you can upgrade your existing solution via ABP CLI. First, you need to install the ABP CLI or upgrade it to the latest version. |
|||
|
|||
If you haven't installed it yet, you can run the following command: |
|||
|
|||
```bash |
|||
dotnet tool install -g Volo.Abp.Studio.Cli |
|||
``` |
|||
|
|||
Or to update the existing CLI, you can run the following command: |
|||
|
|||
```bash |
|||
dotnet tool update -g Volo.Abp.Studio.Cli |
|||
``` |
|||
|
|||
After installing/updating the ABP CLI, you can use the [`update` command](https://abp.io/docs/latest/CLI#update) to update all the ABP related NuGet and NPM packages in your solution as follows: |
|||
|
|||
```bash |
|||
abp update |
|||
``` |
|||
|
|||
You can run this command in the root folder of your solution to update all ABP related packages. |
|||
|
|||
## Migration Guides |
|||
|
|||
There are a few breaking changes in this version that may affect your application. Please read the migration guide carefully, if you are upgrading from v10.1 or earlier versions: [ABP Version 10.2 Migration Guide](https://abp.io/docs/10.2/release-info/migration-guides/abp-10-2) |
|||
|
|||
## Community News |
|||
|
|||
### New ABP Community Articles |
|||
|
|||
As always, exciting articles have been contributed by the ABP community. I will highlight some of them here: |
|||
|
|||
- [Liming Ma](https://abp.io/community/members/maliming) has published 6 new posts: |
|||
- [Dynamic Events in ABP](https://abp.io/community/articles/dynamic-events-in-abp-dukq95m1) |
|||
- [Dynamic Background Jobs and Workers in ABP](https://abp.io/community/articles/dynamic-background-jobs-and-workers-in-abp-wfdkdsq9) |
|||
- [Shared User Accounts in ABP Multi-Tenancy](https://abp.io/community/articles/shared-user-accounts-in-abp-multitenancy-mf3bkg79) |
|||
- [Secure Client Authentication with private_key_jwt in ABP 10.3](https://abp.io/community/articles/secure-client-authentication-with-privatekeyjwt-in-abp-b2rf18bc) |
|||
- [Operation Rate Limiting in ABP Framework](https://abp.io/community/articles/operation-rate-limiting-in-abp-framework-f4jtd6sn) |
|||
- [Resource-Based Authorization in ABP Framework](https://abp.io/community/articles/resourcebased-authorization-in-abp-framework-choku1sn) |
|||
- [One Endpoint, Many AI Clients: Turning ABP Workspaces into OpenAI-Compatible Models](https://abp.io/community/articles/turning-abp-workspaces-into-openai-compatible-endpoints-u3ls1gp4) by [Engincan Veske](https://abp.io/community/members/EngincanV) |
|||
- [Automatically Validate Your Documentation: How We Built a Tutorial Validator](https://abp.io/community/articles/automatically-validate-your-documentation-m3ozgkhv) by [Mansur Besleney](https://abp.io/community/members/mansur.besleney) |
|||
- [Automate Localhost Access for Expo: A Guide to Dynamic Cloudflare Tunnels & Dev Builds](https://abp.io/community/articles/automate-localhost-access-for-expo-a-guide-to-dynamic-7cblqtj3) by [Sumeyye Kurtulus](https://abp.io/community/members/sumeyye.kurtulus) |
|||
|
|||
Thanks to the ABP Community for all the content they have published. You can also [post your ABP related (text or video) content](https://abp.io/community/posts/create) to the ABP Community. |
|||
|
|||
## About the Next Version |
|||
|
|||
The next feature version will be 10.3. You can follow the [release planning here](https://github.com/abpframework/abp/milestones). Please [submit an issue](https://github.com/abpframework/abp/issues/new) if you have any problems with this version. |
|||
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 16 KiB |
@ -0,0 +1,254 @@ |
|||
# ABP Platform 10.3 RC Has Been Released |
|||
|
|||
We are happy to release [ABP](https://abp.io) version **10.3 RC** (Release Candidate). This blog post introduces the new features and important changes in this new version. |
|||
|
|||
Try this version and provide feedback for a more stable version of ABP v10.3! Thanks to you in advance. |
|||
|
|||
## Get Started with the 10.3 RC |
|||
|
|||
You can check the [Get Started page](https://abp.io/get-started) to see how to get started with ABP. You can either download [ABP Studio](https://abp.io/get-started#abp-studio-tab) (**recommended**, if you prefer a user-friendly GUI application - desktop application) or use the [ABP CLI](https://abp.io/docs/latest/cli). |
|||
|
|||
By default, ABP Studio uses stable versions to create solutions. Therefore, if you want to create a solution with a preview version, first you need to create a solution and then switch your solution to the preview version from the ABP Studio UI: |
|||
|
|||
 |
|||
|
|||
## Migration Guide |
|||
|
|||
There are no explicitly marked breaking changes in this version. However, there are still some important migration notes for specific scenarios. Please check the migration guide if you are upgrading from v10.2 or earlier: [ABP Version 10.3 Migration Guide](https://abp.io/docs/10.3/release-info/migration-guides/abp-10-3). |
|||
|
|||
## What's New with ABP v10.3? |
|||
|
|||
In this section, I will introduce some major features released in this version. |
|||
Here is a brief list of titles explained in the next sections: |
|||
|
|||
- OpenIddict: `private_key_jwt` Client Authentication + `abp generate-jwks` |
|||
- Event Bus: String-Based Event Publishing with Dynamic Payload |
|||
- Background Jobs/Workers: String-Based Publishing with Dynamic Payload |
|||
- API Definition Endpoint: Descriptions and Documentation Support |
|||
- Entity Cache: New Batch APIs (`FindMany*` / `GetMany*`) |
|||
- Angular: User/Tenant Sharing and Tenant Switch Experience |
|||
- Angular: Upgrade to 21.2 + TypeScript 5.9 |
|||
- Introducing the `Volo.Abp.LuckyPenny.AutoMapper` Provider |
|||
- Security Improvements (Account Pro Module) |
|||
|
|||
### OpenIddict: `private_key_jwt` Client Authentication + `abp generate-jwks` |
|||
|
|||
ABP v10.3 introduces end-to-end support for OpenIddict `private_key_jwt` client authentication. |
|||
Instead of using a shared `client_secret`, clients can now authenticate with an asymmetric key pair: keep the private key on the client, and register the public key (JWKS) on the authorization server. |
|||
|
|||
On the open-source side, ABP CLI now includes the `abp generate-jwks` command (and the OpenIddict demo was updated accordingly). On the Pro side, OpenIddict application management now supports storing and validating JWKS for confidential applications. |
|||
|
|||
This is especially useful for machine-to-machine and compliance-focused environments where shared secrets are not preferred. |
|||
|
|||
**Example - Generate a JWKS with ABP CLI:** |
|||
|
|||
```bash |
|||
abp generate-jwks --alg RS256 --key-size 2048 -o ./keys -f my-client |
|||
``` |
|||
> See the community article [Secure Client Authentication with private_key_jwt in ABP 10.3](https://abp.io/community/articles/secure-client-authentication-with-privatekeyjwt-in-abp-b2rf18bc) for a full walkthrough. |
|||
> This approach is especially useful for Pro solutions that manage confidential clients in the administration UI. |
|||
|
|||
### Event Bus: String-Based Event Publishing with Dynamic Payload |
|||
|
|||
ABP v10.3 adds string-based publishing and subscription APIs for event-driven integrations. |
|||
|
|||
When you do not know event types at compile time, you can now publish and handle events by name without introducing extra wrapper contracts up front. This is especially useful for plugin ecosystems, partner integrations, and metadata-driven application flows. |
|||
|
|||
This is not a separate eventing model. Dynamic events run through the same ABP infrastructure (including outbox/inbox when configured), can be handled through `DynamicEventData`, and can coexist with typed handlers for the same event name. Distributed providers support this approach except Dapr, which requires startup-time topic declarations. |
|||
|
|||
**Example - Publish by event name:** |
|||
|
|||
```csharp |
|||
await _distributedEventBus.PublishAsync( |
|||
"OrderPlaced", |
|||
new { OrderId = input.Id, CustomerEmail = input.Email } |
|||
); |
|||
``` |
|||
|
|||
**Example - Subscribe dynamically at runtime:** |
|||
|
|||
```csharp |
|||
eventBus.Subscribe("PartnerOrderReceived", |
|||
new PartnerOrderHandler(context.ServiceProvider)); |
|||
|
|||
public class PartnerOrderHandler : IDistributedEventHandler<DynamicEventData> |
|||
{ |
|||
public Task HandleEventAsync(DynamicEventData eventData) |
|||
{ |
|||
// eventData.EventName + eventData.Data |
|||
return Task.CompletedTask; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
> See the community article [Dynamic Events in ABP](https://abp.io/community/articles/dynamic-events-in-abp-dukq95m1) for details. |
|||
|
|||
### Background Jobs/Workers: String-Based Publishing with Dynamic Payload |
|||
|
|||
ABP v10.3 introduces **Dynamic Background Jobs** (`IDynamicBackgroundJobManager`) and **Dynamic Background Workers** (`IDynamicBackgroundWorkerManager`) for runtime registration and execution by name. |
|||
|
|||
With these APIs, you can enqueue jobs with dynamic payloads, register handler delegates at startup, and add/update/remove recurring workers at runtime. This is especially useful for plugin architectures, metadata-driven workflows, and tenant-specific scheduling scenarios where task types are not known at compile time. |
|||
|
|||
Dynamic background jobs work through ABP's existing typed job pipeline (including provider integrations), while dynamic workers support runtime schedule management (period/cron depending on provider). |
|||
|
|||
**Example - Enqueue a job by name with dynamic payload:** |
|||
|
|||
```csharp |
|||
await _dynamicBackgroundJobManager.EnqueueAsync("emails", new |
|||
{ |
|||
EmailAddress = input.CustomerEmail, |
|||
Subject = "Order Confirmed", |
|||
Body = $"Your order {input.OrderId} has been placed." |
|||
}); |
|||
``` |
|||
|
|||
**Example - Update worker schedule at runtime:** |
|||
|
|||
```csharp |
|||
await workerManager.UpdateScheduleAsync( |
|||
"InventorySyncWorker", |
|||
new DynamicBackgroundWorkerSchedule { Period = 10000 } // 10s |
|||
); |
|||
``` |
|||
|
|||
> See [#25059](https://github.com/abpframework/abp/pull/25059) and the community article [Dynamic Background Jobs and Workers in ABP](https://abp.io/community/articles/dynamic-background-jobs-and-workers-in-abp-wfdkdsq9) for details. |
|||
|
|||
### API Definition Endpoint: Descriptions and Documentation Support |
|||
|
|||
The API definition endpoint can now optionally return richer metadata such as summary/description fields for controllers, actions, and parameters. |
|||
|
|||
This is particularly useful for dynamic client generation, API explorers, and tooling that consumes ABP API metadata directly without requiring OpenAPI parsing. |
|||
|
|||
> See [#25022](https://github.com/abpframework/abp/pull/25022) for details. |
|||
|
|||
### Entity Cache: New Batch APIs (`FindMany*` / `GetMany*`) |
|||
|
|||
ABP v10.3 extends `IEntityCache` with batch retrieval APIs so you can resolve multiple entities in a single cache/database flow instead of looping over `FindAsync`/`GetAsync`. |
|||
|
|||
It includes both list-based APIs (`FindManyAsync` / `GetManyAsync`) and dictionary-based APIs (`FindManyAsDictionaryAsync` / `GetManyAsDictionaryAsync`) so you can choose the shape that best matches your access pattern. |
|||
|
|||
**Example - List-based batch retrieval (preserves input order):** |
|||
|
|||
```csharp |
|||
var ids = new List<Guid> { id1, id2, id1 }; |
|||
|
|||
var products = await _productCache.GetManyAsync(ids); // throws if any ID is missing |
|||
var productsOrNull = await _productCache.FindManyAsync(ids); // null for missing IDs |
|||
``` |
|||
|
|||
**Example - Dictionary-based batch retrieval (fast lookup by ID):** |
|||
|
|||
```csharp |
|||
var productsById = await _productCache.GetManyAsDictionaryAsync(ids); |
|||
var nullableProductsById = await _productCache.FindManyAsDictionaryAsync(ids); |
|||
|
|||
if (nullableProductsById.TryGetValue(id1, out var product) && product != null) |
|||
{ |
|||
// use product |
|||
} |
|||
``` |
|||
|
|||
All of these methods are optimized for bulk scenarios by internally batching cache misses via distributed cache multi-get/multi-add operations. |
|||
|
|||
> See [#25088](https://github.com/abpframework/abp/pull/25088) and [#25090](https://github.com/abpframework/abp/pull/25090) for details. |
|||
|
|||
### Angular: User/Tenant Sharing and Tenant Switch Experience |
|||
|
|||
ABP v10.3 enhances Angular UX for shared-user multi-tenancy scenarios, including invitation flows, tenant switch UX, and related identity/account integrations. |
|||
|
|||
This improves the out-of-the-box experience for applications using tenant user sharing. |
|||
|
|||
> See [#25051](https://github.com/abpframework/abp/pull/25051) for details. |
|||
|
|||
### Angular: Upgrade to 21.2 + TypeScript 5.9 |
|||
|
|||
ABP v10.3 upgrades Angular to **21.2** and TypeScript to **5.9**, bringing the Angular UI stack to the latest ABP-supported frontend baseline. |
|||
|
|||
This helps you stay current with the modern Angular and TypeScript ecosystem while benefiting from newer compiler/tooling improvements and maintaining compatibility with the ABP Angular packages in this release. |
|||
|
|||
> See [#25072](https://github.com/abpframework/abp/pull/25072) for details. |
|||
|
|||
### Introducing the `Volo.Abp.LuckyPenny.AutoMapper` Provider |
|||
|
|||
ABP v10.3 introduces `Volo.Abp.LuckyPenny.AutoMapper` as a new optional provider integration for projects that want to use the LuckyPenny-maintained AutoMapper package. |
|||
|
|||
The existing `Volo.Abp.AutoMapper` package remains unchanged, and migration is straightforward: replace `AbpAutoMapperModule` with `AbpLuckyPennyAutoMapperModule` in your module dependencies while keeping the same ABP-facing namespaces and APIs. |
|||
|
|||
This update also addresses the AutoMapper 14.x vulnerability context ([GHSA-rvv3-g6hj-g44x](https://github.com/advisories/GHSA-rvv3-g6hj-g44x)), and ABP documentation was expanded with installation, usage, and migration guidance. For more information, see the documentation: [LuckyPenny AutoMapper Integration](https://abp.io/docs/10.3/framework/infrastructure/luckypenny-automapper). |
|||
|
|||
### Security Improvements (Account Pro Module) |
|||
|
|||
ABP Commercial v10.3 RC also includes notable account security hardening: |
|||
|
|||
- Optional CAPTCHA for forgot-password flow |
|||
- Operation-based rate limiting policies for account confirmation/token operations (including updated/default policies for reset and token endpoints) |
|||
- Session revocation after sensitive credential operations (password change/reset/admin reset) |
|||
- Stronger profile picture upload validation (allowed extensions, max size, and magic-bytes checks) |
|||
|
|||
These changes are security-focused and are designed to be practical for real projects. Here are the key points and how you can tune them: |
|||
|
|||
- **Forgot-password abuse protection**: You can enable CAPTCHA for forgot-password flows to reduce automated reset attempts. |
|||
- **Operation-level rate limiting**: Token/confirmation/reset operations now rely on policy-based limits, so you can centralize and customize limits per operation. |
|||
- **Safer session behavior**: Password changes/resets now revoke sessions to reduce risk from stolen or long-lived sessions. |
|||
- **Profile picture hardening**: Uploads are checked by extension, size, and file signature (magic bytes), not only by client-provided metadata. |
|||
|
|||
**Example - Tune profile picture upload restrictions:** |
|||
|
|||
```csharp |
|||
Configure<AbpProfilePictureOptions>(options => |
|||
{ |
|||
options.AllowedFileExtensions = new[] { ".jpg", ".jpeg", ".png" }; |
|||
options.MaxFileSizeInBytes = 2 * 1024 * 1024; // 2 MB |
|||
}); |
|||
``` |
|||
|
|||
**Example - Override account operation rate-limiting policies:** |
|||
|
|||
```csharp |
|||
Configure<AbpOperationRateLimitingOptions>(options => |
|||
{ |
|||
options.ConfigurePolicy( |
|||
AbpAccountOperationRateLimitPolicies.SendPasswordResetCode, |
|||
policy => |
|||
{ |
|||
policy.ClearRules(); |
|||
policy.PerHour(5); |
|||
policy.PerDay(20); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
> See the community article [Operation Rate Limiting in ABP Framework](https://abp.io/community/articles/operation-rate-limiting-in-abp-framework-f4jtd6sn) for conceptual guidance. |
|||
|
|||
### Other Improvements and Enhancements |
|||
|
|||
- **Permission integration endpoint update**: `PermissionIntegrationController.IsGrantedAsync` now uses `HttpPost` for large payload scenarios ([#25177](https://github.com/abpframework/abp/pull/25177)). |
|||
- **OpenIddict dependency update**: Upgraded to OpenIddict 7.3.0 ([#25053](https://github.com/abpframework/abp/pull/25053)). |
|||
- **Autofac integration update**: Upgraded `Autofac.Extensions.DependencyInjection` to 11.0.0 ([#25190](https://github.com/abpframework/abp/pull/25190)). |
|||
- **MongoDB dependency update**: Bumped MongoDB.Driver to 3.7.1 ([#25114](https://github.com/abpframework/abp/pull/25114)). |
|||
- **OIDC auth storage options for Angular UI (pro)**: OIDC auth storage is now configurable. |
|||
|
|||
## Community News |
|||
|
|||
### New ABP Community Articles |
|||
|
|||
As always, exciting articles have been contributed by the ABP community. I will highlight some of them here: |
|||
|
|||
- [Liming Ma](https://abp.io/community/members/maliming) has published 6 new posts: |
|||
- [Dynamic Events in ABP](https://abp.io/community/articles/dynamic-events-in-abp-dukq95m1) |
|||
- [Dynamic Background Jobs and Workers in ABP](https://abp.io/community/articles/dynamic-background-jobs-and-workers-in-abp-wfdkdsq9) |
|||
- [Shared User Accounts in ABP Multi-Tenancy](https://abp.io/community/articles/shared-user-accounts-in-abp-multitenancy-mf3bkg79) |
|||
- [Secure Client Authentication with private_key_jwt in ABP 10.3](https://abp.io/community/articles/secure-client-authentication-with-privatekeyjwt-in-abp-b2rf18bc) |
|||
- [Operation Rate Limiting in ABP Framework](https://abp.io/community/articles/operation-rate-limiting-in-abp-framework-f4jtd6sn) |
|||
- [Resource-Based Authorization in ABP Framework](https://abp.io/community/articles/resourcebased-authorization-in-abp-framework-choku1sn) |
|||
- [One Endpoint, Many AI Clients: Turning ABP Workspaces into OpenAI-Compatible Models](https://abp.io/community/articles/turning-abp-workspaces-into-openai-compatible-endpoints-u3ls1gp4) by [Engincan Veske](https://abp.io/community/members/EngincanV) |
|||
- [Automatically Validate Your Documentation: How We Built a Tutorial Validator](https://abp.io/community/articles/automatically-validate-your-documentation-m3ozgkhv) by [Mansur Besleney](https://abp.io/community/members/mansur.besleney) |
|||
- [Automate Localhost Access for Expo: A Guide to Dynamic Cloudflare Tunnels & Dev Builds](https://abp.io/community/articles/automate-localhost-access-for-expo-a-guide-to-dynamic-7cblqtj3) by [Sumeyye Kurtulus](https://abp.io/community/members/sumeyye.kurtulus) |
|||
|
|||
Thanks to the ABP Community for all the content they have published. You can also [post your ABP related (text or video) content](https://abp.io/community/posts/create) to the ABP Community. |
|||
|
|||
## Conclusion |
|||
|
|||
This version comes with some new features and a lot of enhancements to the existing features. You can see the [Road Map](https://abp.io/docs/10.3/release-info/road-map) documentation to learn about the release schedule and planned features for the next releases. Please try ABP v10.3 RC and provide feedback to help us release a more stable version. |
|||
|
|||
Thanks for being a part of this community! |
|||
|
After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 17 KiB |
@ -0,0 +1,167 @@ |
|||
# 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 shares a link like `/Books/Detail?id=42&culture=es`. When the server processes the request, it sets the culture cookie and then redirects to `/Books/Detail?id=42` — stripping the `?culture=` parameter. The shared link no longer carries the intended 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<AbpRequestLocalizationOptions>(options => |
|||
{ |
|||
options.UseRouteBasedCulture = true; |
|||
}); |
|||
``` |
|||
|
|||
That is the only change you need to make. |
|||
|
|||
## MVC / Razor Pages |
|||
|
|||
MVC and Razor Pages have the most complete support — everything works automatically. No code changes needed in your pages or controllers. |
|||
|
|||
 |
|||
|
|||
 |
|||
|
|||
## What Happens Automatically |
|||
|
|||
When you set `UseRouteBasedCulture = true`, ABP automatically: |
|||
|
|||
- Registers ASP.NET Core's built-in [`RouteDataRequestCultureProvider`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.localization.routing.routedatarequestcultureprovider) to detect culture from the URL path. |
|||
- Adds a `{culture}/{controller}/{action}` conventional route for MVC controllers, with a route constraint to prevent non-culture URL segments (like `/enterprise/products`) from matching. |
|||
- Adds `{culture}/...` route selectors to all Razor Pages at startup. |
|||
- Injects the current culture into all `Url.Page()` and `Url.Action()` calls, so generated URLs automatically include the culture prefix. |
|||
- Prepends the culture prefix to navigation menu item URLs. |
|||
|
|||
You do not need to configure these individually. |
|||
|
|||
## 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 *@ |
|||
``` |
|||
|
|||
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 already works with route-based culture. When a user switches language, the culture segment in the URL is automatically replaced: |
|||
|
|||
| 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` | |
|||
|
|||
No theme changes, no language switcher changes — the existing UI component just works. |
|||
|
|||
## Blazor Support |
|||
|
|||
Blazor Server and Blazor WebAssembly (WebApp) both support URL-based localization. Culture detection and cookie persistence work automatically on the initial page load (SSR). Menu URLs and language switching also work automatically. |
|||
|
|||
 |
|||
|
|||
 |
|||
|
|||
ABP's built-in module pages (Identity, Settings, etc.) also work with URL-based localization out of the box: |
|||
|
|||
 |
|||
|
|||
### Manual step: Blazor component routes |
|||
|
|||
The only manual step for Blazor is adding `@page "/{culture}/..."` routes to your own pages. ASP.NET Core does not support automatically adding route selectors to Blazor components (unlike Razor Pages), so you must add them explicitly: |
|||
|
|||
```razor |
|||
@page "/" |
|||
@page "/{culture}" |
|||
|
|||
@code { |
|||
[Parameter] |
|||
public string? Culture { get; set; } |
|||
} |
|||
``` |
|||
|
|||
```razor |
|||
@page "/Products" |
|||
@page "/{culture}/Products" |
|||
|
|||
@code { |
|||
[Parameter] |
|||
public string? Culture { get; set; } |
|||
} |
|||
``` |
|||
|
|||
> **ABP's built-in module pages** (Identity, Tenant Management, Settings, Account, etc.) already ship with `@page "/{culture}/..."` route variants. You only need to add these routes to your own application pages. |
|||
|
|||
### Blazor WebApp (WASM) configuration |
|||
|
|||
The WASM client project does not need any `UseRouteBasedCulture` configuration. It reads the setting from the server automatically. |
|||
|
|||
```csharp |
|||
// Server project — the only place you need to configure |
|||
Configure<AbpRequestLocalizationOptions>(options => |
|||
{ |
|||
options.UseRouteBasedCulture = true; |
|||
}); |
|||
``` |
|||
|
|||
## Multi-Tenancy |
|||
|
|||
URL-based localization is fully compatible with ABP's multi-tenant routing. Language switching supports tenant-prefixed URLs, so `/tenant-a/zh-Hans/About` correctly switches to `/tenant-a/en/About` without any additional configuration. |
|||
|
|||
## UI Framework Support Overview |
|||
|
|||
| UI Framework | Route Registration | URL Generation | Menu URLs | Language Switch | Manual Work | |
|||
|---|---|---|---|---|---| |
|||
| **MVC / Razor Pages** | Automatic | Automatic | Automatic | Automatic | None | |
|||
| **Blazor Server** | Manual `@page` routes | N/A | Automatic | Automatic | Add `{culture}` route to pages | |
|||
| **Blazor WebApp (WASM)** | Manual `@page` routes | N/A | Automatic | Automatic | Add `{culture}` route to pages | |
|||
|
|||
## Running the Sample |
|||
|
|||
A runnable sample is available at [abp-samples/UrlBasedLocalization](https://github.com/abpframework/abp-samples/tree/master/UrlBasedLocalization), with three projects: |
|||
|
|||
| Project | UI Type | URL | Command | |
|||
|---|---|---|---| |
|||
| `BookStore.Mvc` | MVC / Razor Pages | `https://localhost:44335` | `dotnet run --project src/BookStore.Mvc` | |
|||
| `BookStore.Blazor.Server` | Blazor Server | `https://localhost:44336` | `dotnet run --project src/BookStore.Blazor.Server` | |
|||
| `BookStore.Blazor.WebApp` | Blazor WebApp (InteractiveAuto) | `https://localhost:44337` | `dotnet run --project src/BookStore.Blazor.WebApp` | |
|||
|
|||
Supported languages: English, Türkçe, Français, 简体中文. |
|||
|
|||
## Summary |
|||
|
|||
To add SEO-friendly localized URL paths to your ABP application: |
|||
|
|||
1. Set `options.UseRouteBasedCulture = true` in your module. |
|||
2. For **Blazor** projects, add `@page "/{culture}/..."` routes to your own pages. |
|||
|
|||
Everything else — route registration, URL generation, menu links, and language switching — is handled automatically. |
|||
|
|||
## References |
|||
|
|||
- [URL-Based Localization — ABP Documentation](https://abp.io/docs/latest/framework/fundamentals/url-based-localization) |
|||
- [Localization — ABP Documentation](https://abp.io/docs/latest/framework/fundamentals/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) |
|||
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 93 KiB |
@ -0,0 +1,173 @@ |
|||
````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<AbpRequestLocalizationOptions>(options => |
|||
{ |
|||
options.UseRouteBasedCulture = true; |
|||
}); |
|||
```` |
|||
|
|||
That's all you need. The framework automatically handles the rest. |
|||
|
|||
## What Happens Automatically |
|||
|
|||
When you set `UseRouteBasedCulture` to `true`, ABP automatically registers the following: |
|||
|
|||
* **`RouteDataRequestCultureProvider`** — A built-in ASP.NET Core provider that reads `{culture}` from route data. ABP inserts it after `QueryStringRequestCultureProvider` and before `CookieRequestCultureProvider`. |
|||
* **`{culture}/{controller}/{action}` route** — A conventional route for MVC controllers. The `{culture}` parameter uses a custom route constraint (`AbpCultureRouteConstraint`) that only matches culture values configured in `AbpLocalizationOptions.Languages`, so URLs like `/enterprise/products` are not mistaken for culture-prefixed routes. |
|||
* **`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 (MVC / Blazor Server). |
|||
* **`AbpWasmCultureMenuItemUrlProvider`** — Prepends the culture prefix to menu item URLs in Blazor WebAssembly (reads the `UseRouteBasedCulture` flag from `/api/abp/application-configuration`). |
|||
|
|||
You do not need to configure these individually. |
|||
|
|||
## 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 |
|||
```` |
|||
|
|||
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 the culture from the request cookie to identify the current page 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. |
|||
|
|||
## MVC / Razor Pages |
|||
|
|||
MVC and Razor Pages have the most complete support. Everything works automatically when `UseRouteBasedCulture = true` — route registration, URL generation, menu links, and language switching. **No code changes are needed in your pages or controllers.** |
|||
|
|||
## Blazor Server |
|||
|
|||
Blazor Server uses SignalR (WebSocket) for the interactive circuit. The HTTP middleware pipeline only runs on the **initial page load** — subsequent interactions happen over the WebSocket connection. ABP handles this by persisting the detected URL culture to a **Cookie** on the first request, so the entire Blazor circuit uses the correct language. |
|||
|
|||
Culture detection, cookie persistence, menu URLs, and language switching all work automatically. No additional configuration is needed beyond the `UseRouteBasedCulture` option. |
|||
|
|||
### What requires manual changes |
|||
|
|||
**Blazor component routes**: ASP.NET Core does not provide an `IPageRouteModelConvention` equivalent for Blazor components. You must manually add the `{culture}` route to each page: |
|||
|
|||
````razor |
|||
@page "/" |
|||
@page "/{culture}" |
|||
|
|||
@code { |
|||
[Parameter] |
|||
public string? Culture { get; set; } |
|||
} |
|||
```` |
|||
|
|||
````razor |
|||
@page "/About" |
|||
@page "/{culture}/About" |
|||
|
|||
@code { |
|||
[Parameter] |
|||
public string? Culture { get; set; } |
|||
} |
|||
```` |
|||
|
|||
> This applies to your own application pages. ABP built-in module pages (Identity, Tenant Management, Settings, Account, etc.) already include `@page "/{culture}/..."` routes out of the box — you do not need to add them manually. |
|||
|
|||
## Blazor WebAssembly (WebApp) |
|||
|
|||
Blazor WebAssembly (WASM) runs in the browser. On the **first page load**, the server renders the page via SSR, and the culture is detected from the URL. After WASM downloads, subsequent renders run in the browser. The WASM app fetches `/api/abp/application-configuration` from the server to get the current culture, so the culture stays consistent. |
|||
|
|||
Culture detection, cookie persistence, menu URLs, and language switching all work automatically. The WASM client reads the `UseRouteBasedCulture` flag from the server via `/api/abp/application-configuration`, so no client-side configuration is needed. |
|||
|
|||
### What requires manual changes |
|||
|
|||
Same as Blazor Server — you must manually add `@page "/{culture}/..."` routes to your Blazor pages. |
|||
|
|||
## Angular |
|||
|
|||
The [ABP Angular UI](../ui/angular/quick-start.md) runs in the browser. The server still applies `UseRouteBasedCulture`; the client reads **`localization.useRouteBasedCulture`** from `/api/abp/application-configuration` (same payload as other UI types). There is no separate Angular setting. |
|||
|
|||
### Routing |
|||
|
|||
Angular does not add a culture segment to your route config automatically. Use **`withOptionalRouteCulturePrefix`** from **`@abp/ng.core`** so one route tree matches both **`/identity/users`** and **`/en/identity/users`** (the first path segment is matched only when it looks like a culture code, e.g. `en`, `tr`, `zh-Hans`). |
|||
|
|||
````typescript |
|||
import { Routes } from '@angular/router'; |
|||
import { withOptionalRouteCulturePrefix } from '@abp/ng.core'; |
|||
|
|||
const appRoutesCore: Routes = [ |
|||
// ... your routes (path: '', 'account', 'identity', lazy children, etc.) |
|||
]; |
|||
|
|||
export const appRoutes = withOptionalRouteCulturePrefix(appRoutesCore); |
|||
```` |
|||
|
|||
 |
|||
|
|||
### URL → session language |
|||
|
|||
When **`useRouteBasedCulture`** is **true**, **`RouteBasedCultureService`** (from `@abp/ng.core`) keeps the session language aligned with the first URL segment after navigation. This runs during application bootstrap and on each **`NavigationEnd`**. |
|||
|
|||
### Menu links, breadcrumbs, and `routerLink` |
|||
|
|||
Menu paths from **`RoutesService`** are usually **without** a culture prefix (`/identity/users`). Use the **`abpRouteCultureUrl`** pipe on **`routerLink`** (or **`RouteBasedCultureUrlService.prefixPathWithCulture`**) so links navigate to **`/en/identity/users`** when route-based culture is enabled. The **Basic** theme navigation and **Theme Shared** breadcrumb links follow this pattern. |
|||
|
|||
 |
|||
|
|||
### Language switcher (toolbar) |
|||
|
|||
If the user selects a language in the UI, call **`RouteBasedCultureUrlService.applyLanguageSelection(cultureName)`** (or **`navigateToUrlWithCulture`**) instead of only updating the session language. That rewrites the current URL’s culture segment (or prepends it) so the address bar and session stay consistent; **`RouteBasedCultureService`** then picks up the culture from the URL after navigation. |
|||
|
|||
### Active menu, breadcrumbs, and route matching |
|||
|
|||
The browser URL may be **`/en/identity/users`** while menu items and **`RoutesService`** paths stay **`/identity/users`**. For comparisons (active state, **`findRoute`**, permission guard, dynamic layout), normalize the current URL with **`RouteBasedCultureUrlService.normalizeForMenuMatch`** (or **`stripCulturePrefixIfEnabled`**) or use **`getRoutePathForMatching`** where **`getRoutePath`** was used. |
|||
|
|||
### Configuration refresh |
|||
|
|||
**`RouteBasedCultureUrlService`** refreshes its cached **`useRouteBasedCulture`** and **languages** when application configuration is updated (for example after **`refreshAppState`**), so hot paths do not query configuration on every change detection cycle. |
|||
|
|||
## 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. |
|||
|
|||
## Culture Detection Priority |
|||
|
|||
ASP.NET Core has a built-in [`RouteDataRequestCultureProvider`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.localization.routing.routedatarequestcultureprovider) (in `Microsoft.AspNetCore.Localization.Routing`) that reads culture from route data, but it is not included in the default provider list. When `UseRouteBasedCulture` is enabled, ABP inserts it after `QueryStringRequestCultureProvider` and before `CookieRequestCultureProvider`. The resulting provider order is: |
|||
|
|||
1. `QueryStringRequestCultureProvider` (ASP.NET Core default — useful for debugging and testing) |
|||
2. `RouteDataRequestCultureProvider` (URL path — inserted by ABP when enabled) |
|||
3. `CookieRequestCultureProvider` (ASP.NET Core default) |
|||
4. `AcceptLanguageHeaderRequestCultureProvider` (ASP.NET Core default) |
|||
|
|||
If a URL contains an invalid culture code (e.g. `/xyz1234/page`), `RequestLocalizationMiddleware` ignores it and falls through to the next provider. No error is thrown. |
|||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 81 KiB |
@ -0,0 +1,76 @@ |
|||
```json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "Upgrade your ABP solutions from v10.2 to v10.3 with this migration guide covering important behavior and integration changes." |
|||
} |
|||
``` |
|||
|
|||
# ABP Version 10.3 Migration Guide |
|||
|
|||
This document is a guide for upgrading ABP v10.2 solutions to ABP v10.3. There are no explicitly marked breaking changes in this release scope, but there are some important changes that may require action in specific application scenarios. |
|||
|
|||
> **Package Version Changes:** Before upgrading, review the [Package Version Changes](../../package-version-changes.md) document to see version changes on dependent NuGet packages and align your project with ABP's internal package versions. |
|||
|
|||
## Open-Source (Framework) |
|||
|
|||
This version contains the following changes on the open-source side: |
|||
|
|||
### `PermissionIntegrationController.IsGrantedAsync` switched to `HttpPost` |
|||
|
|||
**Who is affected** |
|||
|
|||
- Applications that call the permission integration endpoint directly (custom HTTP clients, custom JS, custom integrations). |
|||
|
|||
**What changed** |
|||
|
|||
- `PermissionIntegrationController.IsGrantedAsync` is now `HttpPost` instead of `HttpGet` to avoid URL-length limitations for large permission/user payloads. |
|||
|
|||
**What to do** |
|||
|
|||
- Update custom clients/integrations to send a POST request with payload in the request body. |
|||
- If you rely on generated ABP clients, regenerate/update client proxies after upgrading. |
|||
|
|||
> See [#25177](https://github.com/abpframework/abp/pull/25177) for details. |
|||
|
|||
### `IEntityCache` interface updates for batch dictionary APIs |
|||
|
|||
**Who is affected** |
|||
|
|||
- Applications with custom `IEntityCache` implementations/derivations or custom abstractions built on top of `IEntityCache`. |
|||
|
|||
**What changed** |
|||
|
|||
- New batch methods were added (`FindManyAsync` / `GetManyAsync`, and dictionary variants). |
|||
- `IEntityCache<TEntityCacheItem, TKey>` no longer uses contravariant `in` on `TKey`. |
|||
- `TKey` now has a `notnull` constraint to support dictionary-based APIs. |
|||
|
|||
**What to do** |
|||
|
|||
- Update custom interfaces and implementations to match the latest `IEntityCache` signature and generic constraints. |
|||
- Rebuild solution and fix compile-time variance/constraint issues in custom cache abstractions. |
|||
|
|||
> See [#25088](https://github.com/abpframework/abp/pull/25088) and [#25090](https://github.com/abpframework/abp/pull/25090) for details. |
|||
|
|||
## Pro |
|||
|
|||
There are no explicitly marked breaking changes on the PRO side in this release scope. However, check the following if they apply to your application. |
|||
|
|||
### Account Pro and Identity Pro security behavior updates |
|||
|
|||
**Who is affected** |
|||
|
|||
- Applications using the **Account Pro** and **Identity Pro** modules for password reset/change and profile picture upload flows. |
|||
|
|||
**What changed** |
|||
|
|||
- Session revocation behavior was tightened after sensitive credential operations. |
|||
- Profile picture upload now enforces extension/size/magic-bytes validation by default. |
|||
- Forgot-password CAPTCHA and account-manage rate limiting capabilities were extended. |
|||
|
|||
**What to do** |
|||
|
|||
In most cases, no action is required on your part. However, please review the following considerations: |
|||
|
|||
- Re-test your sign-in, password reset, and password change flows, especially if you have session continuity expectations. |
|||
- If your application needs additional image types/sizes, configure `AbpProfilePictureOptions` explicitly. |
|||
- Review account security settings (`UseCaptchaOnForgotPassword` and related rate-limit options) and adjust them to your UX/security requirements. |
|||
@ -0,0 +1,63 @@ |
|||
using System; |
|||
using System.Globalization; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.AspNetCore.Mvc.Client; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.UI.Navigation; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Components.WebAssembly.Theming; |
|||
|
|||
/// <summary>
|
|||
/// Prepends the culture route prefix to menu item URLs in Blazor WebAssembly when route-based culture is enabled.
|
|||
/// </summary>
|
|||
public class AbpWasmCultureMenuItemUrlProvider : IMenuItemUrlProvider, ITransientDependency |
|||
{ |
|||
protected ICachedApplicationConfigurationClient ConfigurationClient { get; } |
|||
protected IMenuItemCulturePrefixHelper MenuItemCulturePrefixHelper { get; } |
|||
|
|||
public AbpWasmCultureMenuItemUrlProvider( |
|||
ICachedApplicationConfigurationClient configurationClient, |
|||
IMenuItemCulturePrefixHelper menuItemCulturePrefixHelper) |
|||
{ |
|||
ConfigurationClient = configurationClient; |
|||
MenuItemCulturePrefixHelper = menuItemCulturePrefixHelper; |
|||
} |
|||
|
|||
public virtual async Task HandleAsync(MenuItemUrlProviderContext context) |
|||
{ |
|||
if (!OperatingSystem.IsBrowser()) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var config = await ConfigurationClient.GetAsync(); |
|||
if (!config.Localization.UseRouteBasedCulture) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var culture = GetCulture(config); |
|||
if (string.IsNullOrEmpty(culture)) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
await MenuItemCulturePrefixHelper.PrependCulturePrefixAsync(context.Menu, "/" + culture); |
|||
} |
|||
|
|||
protected virtual string? GetCulture(Mvc.ApplicationConfigurations.ApplicationConfigurationDto config) |
|||
{ |
|||
var currentCulture = CultureInfo.CurrentCulture.Name; |
|||
var languages = config.Localization.Languages; |
|||
if (languages.Count == 0) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var isKnownCulture = languages |
|||
.Any(l => string.Equals(l.CultureName, currentCulture, StringComparison.OrdinalIgnoreCase)); |
|||
|
|||
return isKnownCulture ? currentCulture : null; |
|||
} |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
using Microsoft.AspNetCore.Components; |
|||
using Volo.Abp.AspNetCore.Components; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Components.WebAssembly.Theming; |
|||
|
|||
/// <summary>
|
|||
/// Shared base for WASM theme <c>Authentication</c> pages.
|
|||
/// Provides a <see cref="GetCultureAwareHomeUrl"/> helper so the culture-aware
|
|||
/// home URL construction is not duplicated across theme packages.
|
|||
/// </summary>
|
|||
public abstract class CultureAwareAuthenticationBase : AbpComponentBase |
|||
{ |
|||
[Inject] |
|||
protected NavigationManager Navigation { get; set; } = default!; |
|||
|
|||
[Parameter] |
|||
public string? Culture { get; set; } |
|||
|
|||
protected virtual string GetCultureAwareHomeUrl() |
|||
{ |
|||
return string.IsNullOrEmpty(Culture) |
|||
? Navigation.BaseUri |
|||
: Navigation.BaseUri + Culture + "/"; |
|||
} |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Components; |
|||
using Microsoft.AspNetCore.Components.WebAssembly.Authentication; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.AspNetCore.Components.Web; |
|||
using Volo.Abp.AspNetCore.Components.WebAssembly; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Components.WebAssembly.Theming; |
|||
|
|||
/// <summary>
|
|||
/// Provides the shared culture-aware login redirect logic for all WASM theme
|
|||
/// <c>WebAssemblyRedirectToLogin</c> components. Each theme must keep
|
|||
/// <c>@inherits RedirectToLogin</c> for ABP service-replacement assignability,
|
|||
/// so a common component base class is not feasible; this static helper
|
|||
/// centralises the logic instead.
|
|||
/// </summary>
|
|||
public static class CultureAwareRedirectToLoginHelper |
|||
{ |
|||
public static async Task RedirectAsync( |
|||
NavigationManager navigation, |
|||
string loginUrl, |
|||
IRouteBasedCultureUrlHelper cultureUrlHelper, |
|||
IOptions<AbpAspNetCoreComponentsWebOptions> webOptions) |
|||
{ |
|||
var cultureLoginUrl = await cultureUrlHelper.PrependCulturePrefixAsync(loginUrl); |
|||
if (webOptions.Value.IsBlazorWebApp) |
|||
{ |
|||
navigation.NavigateTo(cultureLoginUrl, forceLoad: true); |
|||
} |
|||
else |
|||
{ |
|||
navigation.NavigateToLogin(cultureLoginUrl); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Components; |
|||
using Volo.Abp.Localization; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Components.WebAssembly; |
|||
|
|||
public interface IRouteBasedCultureNavigationHelper |
|||
{ |
|||
Task NavigateToNewCultureAsync( |
|||
NavigationManager navigationManager, |
|||
LanguageInfo newLanguage, |
|||
IEnumerable<LanguageInfo> allLanguages); |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Components.WebAssembly; |
|||
|
|||
public interface IRouteBasedCultureUrlHelper |
|||
{ |
|||
/// <summary>
|
|||
/// Prepends the current culture to <paramref name="url"/> when route-based culture is enabled
|
|||
/// and the current culture is a known application language.
|
|||
/// Returns the original <paramref name="url"/> unchanged when the feature is disabled or the
|
|||
/// culture is not recognised.
|
|||
/// </summary>
|
|||
Task<string> PrependCulturePrefixAsync(string url); |
|||
} |
|||
@ -0,0 +1,56 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Components; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Localization; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Components.WebAssembly; |
|||
|
|||
public class RouteBasedCultureNavigationHelper : IRouteBasedCultureNavigationHelper, ITransientDependency |
|||
{ |
|||
public virtual Task NavigateToNewCultureAsync( |
|||
NavigationManager navigationManager, |
|||
LanguageInfo newLanguage, |
|||
IEnumerable<LanguageInfo> allLanguages) |
|||
{ |
|||
var relativePath = navigationManager.ToBaseRelativePath(navigationManager.Uri); |
|||
|
|||
// Separate the path from any query string or fragment so the culture segment
|
|||
// is correctly identified even for URLs like "tr?x=1" (no slash after culture).
|
|||
var suffixIndex = relativePath.IndexOfAny(['?', '#']); |
|||
var pathPart = suffixIndex >= 0 ? relativePath.Substring(0, suffixIndex) : relativePath; |
|||
var suffix = suffixIndex >= 0 ? relativePath.Substring(suffixIndex) : string.Empty; |
|||
|
|||
var slashIndex = pathPart.IndexOf('/'); |
|||
var firstSegment = GetFirstPathSegment(relativePath); |
|||
var pathRemainder = slashIndex >= 0 ? pathPart.Substring(slashIndex) : string.Empty; |
|||
|
|||
// No-op: the current URL already shows the target culture — no navigation needed.
|
|||
if (string.Equals(firstSegment, newLanguage.CultureName, StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
var newRelativePath = allLanguages.Any(l => string.Equals(l.CultureName, firstSegment, StringComparison.OrdinalIgnoreCase)) |
|||
? newLanguage.CultureName + pathRemainder + suffix |
|||
: newLanguage.CultureName + "/" + pathPart + suffix; |
|||
|
|||
navigationManager.NavigateTo(navigationManager.ToAbsoluteUri(newRelativePath).ToString(), forceLoad: true); |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Returns the first path segment of <paramref name="baseRelativePath"/>,
|
|||
/// stripping any query string or fragment before splitting on '/'.
|
|||
/// For example: "zh-Hans/account?x=1" → "zh-Hans", "tr/home#top" → "tr".
|
|||
/// </summary>
|
|||
protected virtual string GetFirstPathSegment(string baseRelativePath) |
|||
{ |
|||
var suffixIndex = baseRelativePath.IndexOfAny(['?', '#']); |
|||
var pathPart = suffixIndex >= 0 ? baseRelativePath.Substring(0, suffixIndex) : baseRelativePath; |
|||
var slashIndex = pathPart.IndexOf('/'); |
|||
return slashIndex >= 0 ? pathPart.Substring(0, slashIndex) : pathPart; |
|||
} |
|||
} |
|||
@ -0,0 +1,90 @@ |
|||
using System; |
|||
using System.Globalization; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.AspNetCore.Mvc.Client; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Components.WebAssembly; |
|||
|
|||
public class RouteBasedCultureUrlHelper : IRouteBasedCultureUrlHelper, ITransientDependency |
|||
{ |
|||
private readonly ICachedApplicationConfigurationClient _configurationClient; |
|||
|
|||
public RouteBasedCultureUrlHelper(ICachedApplicationConfigurationClient configurationClient) |
|||
{ |
|||
_configurationClient = configurationClient; |
|||
} |
|||
|
|||
public virtual async Task<string> PrependCulturePrefixAsync(string url) |
|||
{ |
|||
if (string.IsNullOrEmpty(url)) |
|||
{ |
|||
return url; |
|||
} |
|||
|
|||
// Skip absolute URLs with a web scheme and protocol-relative URLs.
|
|||
// Intentionally avoids Uri.TryCreate here: on Unix, root-relative paths such as
|
|||
// "/account/login" are parsed as absolute file:// URIs, which would incorrectly
|
|||
// skip them before the culture prefix could be applied.
|
|||
if (url.StartsWith("//", StringComparison.Ordinal) || |
|||
url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || |
|||
url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
return url; |
|||
} |
|||
|
|||
var config = await _configurationClient.GetAsync(); |
|||
if (config?.Localization.UseRouteBasedCulture != true) |
|||
{ |
|||
return url; |
|||
} |
|||
|
|||
var currentCulture = CultureInfo.CurrentCulture.Name; |
|||
var isKnownCulture = config.Localization.Languages |
|||
.Any(l => string.Equals(l.CultureName, currentCulture, StringComparison.OrdinalIgnoreCase)); |
|||
|
|||
if (!isKnownCulture) |
|||
{ |
|||
return url; |
|||
} |
|||
|
|||
// Idempotency guard: if the URL already carries the culture prefix, return it unchanged.
|
|||
// Strip the leading scheme prefix (~/ or /) before checking the first path segment.
|
|||
var pathForSegmentCheck = url.StartsWith("~/", StringComparison.Ordinal) ? url.Substring(2) |
|||
: url.StartsWith("/", StringComparison.Ordinal) ? url.Substring(1) |
|||
: url; |
|||
|
|||
if (string.Equals(GetFirstPathSegment(pathForSegmentCheck), |
|||
currentCulture, StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
return url; |
|||
} |
|||
|
|||
if (url.StartsWith("~/", StringComparison.Ordinal)) |
|||
{ |
|||
return "~/" + currentCulture + "/" + url.Substring(2); |
|||
} |
|||
|
|||
if (url.StartsWith("/", StringComparison.Ordinal)) |
|||
{ |
|||
return "/" + currentCulture + url; |
|||
} |
|||
|
|||
// Bare relative path (e.g. "authentication/login")
|
|||
return currentCulture + "/" + url; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Returns the first path segment of <paramref name="baseRelativePath"/>,
|
|||
/// stripping any query string or fragment before splitting on '/'.
|
|||
/// For example: "zh-Hans/account?x=1" → "zh-Hans", "tr/home#top" → "tr".
|
|||
/// </summary>
|
|||
protected virtual string GetFirstPathSegment(string baseRelativePath) |
|||
{ |
|||
var suffixIndex = baseRelativePath.IndexOfAny(['?', '#']); |
|||
var pathPart = suffixIndex >= 0 ? baseRelativePath.Substring(0, suffixIndex) : baseRelativePath; |
|||
var slashIndex = pathPart.IndexOf('/'); |
|||
return slashIndex >= 0 ? pathPart.Substring(0, slashIndex) : pathPart; |
|||
} |
|||
} |
|||
@ -0,0 +1,71 @@ |
|||
using Microsoft.AspNetCore.Mvc; |
|||
using Microsoft.AspNetCore.Mvc.Routing; |
|||
using Microsoft.AspNetCore.Routing; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.Localization; |
|||
|
|||
/// <summary>
|
|||
/// Wraps an <see cref="IUrlHelper"/> to automatically inject the culture route value
|
|||
/// into all URL generation calls.
|
|||
/// </summary>
|
|||
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, |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,69 @@ |
|||
using System; |
|||
using System.Globalization; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Http; |
|||
using Microsoft.AspNetCore.RequestLocalization; |
|||
using Microsoft.AspNetCore.Routing; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Localization; |
|||
using Volo.Abp.UI.Navigation; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.Localization; |
|||
|
|||
/// <summary>
|
|||
/// Prepends the culture route prefix to menu item URLs when route-based culture is enabled.
|
|||
/// </summary>
|
|||
public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider, ITransientDependency |
|||
{ |
|||
protected IHttpContextAccessor HttpContextAccessor { get; } |
|||
protected IOptions<AbpRequestLocalizationOptions> LocalizationOptions { get; } |
|||
protected IOptions<AbpLocalizationOptions> AbpLocalizationOptions { get; } |
|||
protected IMenuItemCulturePrefixHelper MenuItemCulturePrefixHelper { get; } |
|||
|
|||
public AbpCultureMenuItemUrlProvider( |
|||
IHttpContextAccessor httpContextAccessor, |
|||
IOptions<AbpRequestLocalizationOptions> localizationOptions, |
|||
IOptions<AbpLocalizationOptions> abpLocalizationOptions, |
|||
IMenuItemCulturePrefixHelper menuItemCulturePrefixHelper) |
|||
{ |
|||
HttpContextAccessor = httpContextAccessor; |
|||
LocalizationOptions = localizationOptions; |
|||
AbpLocalizationOptions = abpLocalizationOptions; |
|||
MenuItemCulturePrefixHelper = menuItemCulturePrefixHelper; |
|||
} |
|||
|
|||
public virtual async Task HandleAsync(MenuItemUrlProviderContext context) |
|||
{ |
|||
if (!LocalizationOptions.Value.UseRouteBasedCulture) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var culture = GetCulture(); |
|||
if (string.IsNullOrEmpty(culture)) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
await MenuItemCulturePrefixHelper.PrependCulturePrefixAsync(context.Menu, "/" + culture); |
|||
} |
|||
|
|||
protected virtual string? GetCulture() |
|||
{ |
|||
var httpContext = HttpContextAccessor.HttpContext; |
|||
if (httpContext != null) |
|||
{ |
|||
return AbpRequestCultureCookieHelper.GetRouteCulture(httpContext); |
|||
} |
|||
|
|||
// No HttpContext: fallback to CurrentCulture.
|
|||
var currentCulture = CultureInfo.CurrentCulture.Name; |
|||
var isKnownCulture = AbpLocalizationOptions.Value.Languages |
|||
.Any(l => string.Equals(l.CultureName, currentCulture, StringComparison.OrdinalIgnoreCase)); |
|||
|
|||
return isKnownCulture ? currentCulture : null; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
using System; |
|||
using System.Linq; |
|||
using Microsoft.AspNetCore.Http; |
|||
using Microsoft.AspNetCore.Routing; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.Localization; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.Localization; |
|||
|
|||
/// <summary>
|
|||
/// A route constraint that only matches culture values configured in
|
|||
/// <see cref="AbpLocalizationOptions.Languages"/>.
|
|||
/// </summary>
|
|||
public class AbpCultureRouteConstraint : IRouteConstraint |
|||
{ |
|||
public virtual bool Match(HttpContext? httpContext, IRouter? route, string routeKey, |
|||
RouteValueDictionary values, RouteDirection routeDirection) |
|||
{ |
|||
if (!values.TryGetValue(routeKey, out var value) || value is not string cultureValue) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
var languages = httpContext?.RequestServices |
|||
.GetService<IOptions<AbpLocalizationOptions>>()?.Value.Languages; |
|||
|
|||
if (languages == null || languages.Count == 0) |
|||
{ |
|||
// During URL generation, HttpContext or services may not be available.
|
|||
return routeDirection == RouteDirection.UrlGeneration; |
|||
} |
|||
|
|||
return languages.Any(l => |
|||
string.Equals(l.CultureName, cultureValue, StringComparison.OrdinalIgnoreCase)); |
|||
} |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using Microsoft.AspNetCore.Mvc.ApplicationModels; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.Localization; |
|||
|
|||
/// <summary>
|
|||
/// Adds a {culture}-prefixed route selector to every Razor Page.
|
|||
/// Automatically registered when <c>UseRouteBasedCulture</c> is <c>true</c>.
|
|||
/// </summary>
|
|||
public class AbpCultureRoutePagesConvention : IPageRouteModelConvention |
|||
{ |
|||
/// <summary>
|
|||
/// Route parameter template using the custom "culture" route constraint registered in
|
|||
/// <see cref="AbpCultureRouteConstraint"/>. The constraint only matches culture values
|
|||
/// configured in <see cref="Volo.Abp.Localization.AbpLocalizationOptions.Languages"/>.
|
|||
/// </summary>
|
|||
internal const string CultureRouteTemplate = "{culture:culture}"; |
|||
|
|||
public virtual void Apply(PageRouteModel model) |
|||
{ |
|||
var selectorsToAdd = new List<SelectorModel>(); |
|||
|
|||
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); |
|||
} |
|||
} |
|||
} |
|||
@ -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; |
|||
|
|||
/// <summary>
|
|||
/// Wraps the default <see cref="UrlHelperFactory"/> to automatically inject the culture
|
|||
/// route value into all URL generation calls when the current request has a {culture} route value.
|
|||
/// Only activates when <see cref="AbpRequestLocalizationOptions.UseRouteBasedCulture"/> is <c>true</c>.
|
|||
/// </summary>
|
|||
public class AbpCultureRouteUrlHelperFactory : IUrlHelperFactory |
|||
{ |
|||
protected UrlHelperFactory Inner { get; } |
|||
protected IOptions<AbpRequestLocalizationOptions> LocalizationOptions { get; } |
|||
|
|||
public AbpCultureRouteUrlHelperFactory( |
|||
UrlHelperFactory inner, |
|||
IOptions<AbpRequestLocalizationOptions> 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); |
|||
} |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Volo.Abp.UI.Navigation; |
|||
|
|||
public interface IMenuItemCulturePrefixHelper |
|||
{ |
|||
Task PrependCulturePrefixAsync(IHasMenuItems menuWithItems, string prefix); |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Volo.Abp.UI.Navigation; |
|||
|
|||
/// <summary>
|
|||
/// 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).
|
|||
/// </summary>
|
|||
public interface IMenuItemUrlProvider |
|||
{ |
|||
Task HandleAsync(MenuItemUrlProviderContext context); |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace Volo.Abp.UI.Navigation; |
|||
|
|||
public class MenuItemCulturePrefixHelper : IMenuItemCulturePrefixHelper, ITransientDependency |
|||
{ |
|||
public virtual Task PrependCulturePrefixAsync(IHasMenuItems menuWithItems, string prefix) |
|||
{ |
|||
PrependCulturePrefix(menuWithItems, prefix); |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
protected virtual void PrependCulturePrefix(IHasMenuItems menuWithItems, string prefix) |
|||
{ |
|||
foreach (var item in menuWithItems.Items) |
|||
{ |
|||
if (item.Url != null) |
|||
{ |
|||
if (item.Url.StartsWith("~/")) |
|||
{ |
|||
item.Url = "~" + prefix + item.Url.Substring(1); |
|||
} |
|||
else if (item.Url.StartsWith("/")) |
|||
{ |
|||
item.Url = prefix + item.Url; |
|||
} |
|||
} |
|||
|
|||
PrependCulturePrefix(item, prefix); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
namespace Volo.Abp.UI.Navigation; |
|||
|
|||
public class MenuItemUrlProviderContext |
|||
{ |
|||
public ApplicationMenu Menu { get; } |
|||
|
|||
public MenuItemUrlProviderContext(ApplicationMenu menu) |
|||
{ |
|||
Menu = menu; |
|||
} |
|||
} |
|||
@ -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<IUrlHelper>(); |
|||
inner.Action(Arg.Any<UrlActionContext>()).Returns(callInfo => |
|||
{ |
|||
var ctx = callInfo.Arg<UrlActionContext>(); |
|||
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<IUrlHelper>(); |
|||
inner.Action(Arg.Any<UrlActionContext>()).Returns(callInfo => |
|||
{ |
|||
var ctx = callInfo.Arg<UrlActionContext>(); |
|||
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<IUrlHelper>(); |
|||
inner.RouteUrl(Arg.Any<UrlRouteContext>()).Returns(callInfo => |
|||
{ |
|||
var ctx = callInfo.Arg<UrlRouteContext>(); |
|||
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<IUrlHelper>(); |
|||
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<IUrlHelper>(); |
|||
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<AbpCultureAwareUrlHelper>(); |
|||
} |
|||
|
|||
[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<AbpCultureAwareUrlHelper>(); |
|||
} |
|||
|
|||
[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<AbpCultureAwareUrlHelper>(); |
|||
} |
|||
|
|||
private static AbpCultureRouteUrlHelperFactory CreateFactory(bool useRouteBasedCulture) |
|||
{ |
|||
return new AbpCultureRouteUrlHelperFactory( |
|||
new UrlHelperFactory(), |
|||
Microsoft.Extensions.Options.Options.Create(new AbpRequestLocalizationOptions { UseRouteBasedCulture = useRouteBasedCulture })); |
|||
} |
|||
} |
|||
@ -0,0 +1,280 @@ |
|||
using System; |
|||
using System.Globalization; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Http; |
|||
using Microsoft.AspNetCore.RequestLocalization; |
|||
using Microsoft.AspNetCore.Routing; |
|||
using Shouldly; |
|||
using MsOptions = Microsoft.Extensions.Options.Options; |
|||
using Volo.Abp.Localization; |
|||
using Volo.Abp.UI.Navigation; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.Localization; |
|||
|
|||
public class AbpCultureMenuItemUrlProvider_Tests |
|||
{ |
|||
[Fact] |
|||
public async Task Should_Not_Modify_Urls_When_RouteBasedCulture_Is_Disabled() |
|||
{ |
|||
var provider = CreateProvider(useRouteBasedCulture: false, cultureName: "zh-Hans"); |
|||
var menu = CreateMenuWithItems("/home", "/about"); |
|||
|
|||
await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); |
|||
|
|||
menu.Items[0].Url.ShouldBe("/home"); |
|||
menu.Items[1].Url.ShouldBe("/about"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Prepend_Culture_Prefix_When_Route_Has_Culture() |
|||
{ |
|||
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "zh-Hans"); |
|||
var menu = CreateMenuWithItems("/home", "/about"); |
|||
|
|||
await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); |
|||
|
|||
menu.Items[0].Url.ShouldBe("/zh-Hans/home"); |
|||
menu.Items[1].Url.ShouldBe("/zh-Hans/about"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Add_Prefix_For_Mvc_Request_Without_Culture() |
|||
{ |
|||
// MVC request to /about (no culture, no HasRouteCulture cookie).
|
|||
var httpContext = new DefaultHttpContext(); |
|||
httpContext.Request.RouteValues["controller"] = "Home"; |
|||
httpContext.Request.RouteValues["action"] = "About"; |
|||
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; |
|||
var localizationOptions = MsOptions.Create( |
|||
new AbpRequestLocalizationOptions { UseRouteBasedCulture = true }); |
|||
var abpLocOptions = new AbpLocalizationOptions(); |
|||
abpLocOptions.Languages.Add(new LanguageInfo("en")); |
|||
abpLocOptions.Languages.Add(new LanguageInfo("zh-Hans")); |
|||
var provider = new AbpCultureMenuItemUrlProvider( |
|||
httpContextAccessor, localizationOptions, MsOptions.Create(abpLocOptions), new MenuItemCulturePrefixHelper()); |
|||
|
|||
var menu = CreateMenuWithItems("/home", "/about"); |
|||
|
|||
var previousCulture = CultureInfo.CurrentCulture; |
|||
try |
|||
{ |
|||
CultureInfo.CurrentCulture = new CultureInfo("zh-Hans"); |
|||
await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); |
|||
} |
|||
finally |
|||
{ |
|||
CultureInfo.CurrentCulture = previousCulture; |
|||
} |
|||
|
|||
menu.Items[0].Url.ShouldBe("/home"); |
|||
menu.Items[1].Url.ShouldBe("/about"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Fallback_To_CurrentCulture_In_Blazor_Circuit() |
|||
{ |
|||
// Blazor Server interactive circuit: HttpContext exists (SignalR) but has
|
|||
// no route culture. Cookie was set during SSR indicating route culture was used.
|
|||
var httpContext = new DefaultHttpContext(); |
|||
httpContext.Request.Headers["Cookie"] = $"{AbpRequestCultureCookieHelper.HasRouteCultureCookieName}=1"; |
|||
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; |
|||
var localizationOptions = MsOptions.Create( |
|||
new AbpRequestLocalizationOptions { UseRouteBasedCulture = true }); |
|||
var abpLocOptions = new AbpLocalizationOptions(); |
|||
abpLocOptions.Languages.Add(new LanguageInfo("en")); |
|||
abpLocOptions.Languages.Add(new LanguageInfo("zh-Hans")); |
|||
var provider = new AbpCultureMenuItemUrlProvider( |
|||
httpContextAccessor, localizationOptions, MsOptions.Create(abpLocOptions), new MenuItemCulturePrefixHelper()); |
|||
|
|||
var menu = CreateMenuWithItems("/home", "/about"); |
|||
|
|||
var previousCulture = CultureInfo.CurrentCulture; |
|||
try |
|||
{ |
|||
CultureInfo.CurrentCulture = new CultureInfo("zh-Hans"); |
|||
await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); |
|||
} |
|||
finally |
|||
{ |
|||
CultureInfo.CurrentCulture = previousCulture; |
|||
} |
|||
|
|||
menu.Items[0].Url.ShouldBe("/zh-Hans/home"); |
|||
menu.Items[1].Url.ShouldBe("/zh-Hans/about"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Use_CurrentCulture_Fallback_When_No_HttpContext() |
|||
{ |
|||
// Simulates Blazor interactive circuit: no HttpContext, but CurrentCulture is set.
|
|||
// CurrentCulture (not CurrentUICulture) is used because {culture} route segments
|
|||
// represent the culture, not the UI culture.
|
|||
var provider = CreateProviderWithoutHttpContext( |
|||
useRouteBasedCulture: true, |
|||
knownLanguages: new[] { "en", "zh-Hans", "tr" }); |
|||
|
|||
var menu = CreateMenuWithItems("/home", "/about"); |
|||
|
|||
var previousCulture = CultureInfo.CurrentCulture; |
|||
try |
|||
{ |
|||
CultureInfo.CurrentCulture = new CultureInfo("zh-Hans"); |
|||
await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); |
|||
} |
|||
finally |
|||
{ |
|||
CultureInfo.CurrentCulture = previousCulture; |
|||
} |
|||
|
|||
menu.Items[0].Url.ShouldBe("/zh-Hans/home"); |
|||
menu.Items[1].Url.ShouldBe("/zh-Hans/about"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Modify_Urls_When_No_HttpContext_And_Unknown_Culture() |
|||
{ |
|||
// Blazor interactive circuit with a culture that is not in the known languages list
|
|||
var provider = CreateProviderWithoutHttpContext( |
|||
useRouteBasedCulture: true, |
|||
knownLanguages: new[] { "en", "tr" }); |
|||
|
|||
var menu = CreateMenuWithItems("/home", "/about"); |
|||
|
|||
var previousCulture = CultureInfo.CurrentCulture; |
|||
try |
|||
{ |
|||
CultureInfo.CurrentCulture = new CultureInfo("fr"); |
|||
await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); |
|||
} |
|||
finally |
|||
{ |
|||
CultureInfo.CurrentCulture = previousCulture; |
|||
} |
|||
|
|||
menu.Items[0].Url.ShouldBe("/home"); |
|||
menu.Items[1].Url.ShouldBe("/about"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Prepend_Prefix_Recursively_For_Nested_Items() |
|||
{ |
|||
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "tr"); |
|||
|
|||
var menu = new ApplicationMenu("TestMenu"); |
|||
var parent = new ApplicationMenuItem("Parent", "Parent", url: "/parent"); |
|||
var child = new ApplicationMenuItem("Child", "Child", url: "/child"); |
|||
var grandChild = new ApplicationMenuItem("GrandChild", "GrandChild", url: "/grandchild"); |
|||
child.AddItem(grandChild); |
|||
parent.AddItem(child); |
|||
menu.AddItem(parent); |
|||
|
|||
await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); |
|||
|
|||
parent.Url.ShouldBe("/tr/parent"); |
|||
child.Url.ShouldBe("/tr/child"); |
|||
grandChild.Url.ShouldBe("/tr/grandchild"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Handle_Tilde_Slash_Urls() |
|||
{ |
|||
// ~/identity/users is the pattern used by ABP module menu contributors (e.g. Identity)
|
|||
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "zh-Hans"); |
|||
|
|||
var menu = new ApplicationMenu("TestMenu"); |
|||
menu.AddItem(new ApplicationMenuItem("Users", "Users", url: "~/identity/users")); |
|||
menu.AddItem(new ApplicationMenuItem("Roles", "Roles", url: "~/identity/roles")); |
|||
|
|||
await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); |
|||
|
|||
// ~/identity/users → ~/zh-Hans/identity/users
|
|||
// Blazor theme strips "~/" via TrimStart('/', '~') → "zh-Hans/identity/users"
|
|||
// With <base href="/"> resolves to /zh-Hans/identity/users
|
|||
menu.Items[0].Url.ShouldBe("~/zh-Hans/identity/users"); |
|||
menu.Items[1].Url.ShouldBe("~/zh-Hans/identity/roles"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Modify_External_Urls() |
|||
{ |
|||
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "zh-Hans"); |
|||
|
|||
var menu = new ApplicationMenu("TestMenu"); |
|||
menu.AddItem(new ApplicationMenuItem("External", "External", url: "https://example.com/page")); |
|||
menu.AddItem(new ApplicationMenuItem("Relative", "Relative", url: "page")); |
|||
menu.AddItem(new ApplicationMenuItem("Local", "Local", url: "/local")); |
|||
|
|||
await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); |
|||
|
|||
// External and relative URLs should not be modified
|
|||
menu.Items[0].Url.ShouldBe("https://example.com/page"); |
|||
menu.Items[1].Url.ShouldBe("page"); |
|||
// Local URL should be prefixed
|
|||
menu.Items[2].Url.ShouldBe("/zh-Hans/local"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Throw_When_Url_Is_Null() |
|||
{ |
|||
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "tr"); |
|||
|
|||
var menu = new ApplicationMenu("TestMenu"); |
|||
menu.AddItem(new ApplicationMenuItem("NoUrl", "No URL", url: null)); |
|||
menu.AddItem(new ApplicationMenuItem("WithUrl", "With URL", url: "/page")); |
|||
|
|||
await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); |
|||
|
|||
// Null URL should remain null
|
|||
menu.Items[0].Url.ShouldBeNull(); |
|||
// Normal URL should be prefixed
|
|||
menu.Items[1].Url.ShouldBe("/tr/page"); |
|||
} |
|||
|
|||
private static AbpCultureMenuItemUrlProvider CreateProvider( |
|||
bool useRouteBasedCulture, |
|||
string? cultureName) |
|||
{ |
|||
var httpContext = new DefaultHttpContext(); |
|||
if (cultureName != null) |
|||
{ |
|||
httpContext.Request.RouteValues["culture"] = cultureName; |
|||
} |
|||
|
|||
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; |
|||
var localizationOptions = MsOptions.Create( |
|||
new AbpRequestLocalizationOptions { UseRouteBasedCulture = useRouteBasedCulture }); |
|||
var abpLocalizationOptions = MsOptions.Create(new AbpLocalizationOptions()); |
|||
|
|||
return new AbpCultureMenuItemUrlProvider( |
|||
httpContextAccessor, localizationOptions, abpLocalizationOptions, new MenuItemCulturePrefixHelper()); |
|||
} |
|||
|
|||
private static AbpCultureMenuItemUrlProvider CreateProviderWithoutHttpContext( |
|||
bool useRouteBasedCulture, |
|||
string[] knownLanguages) |
|||
{ |
|||
var httpContextAccessor = new HttpContextAccessor { HttpContext = null }; |
|||
var localizationOptions = MsOptions.Create( |
|||
new AbpRequestLocalizationOptions { UseRouteBasedCulture = useRouteBasedCulture }); |
|||
var abpLocOptions = new AbpLocalizationOptions(); |
|||
foreach (var lang in knownLanguages) |
|||
{ |
|||
abpLocOptions.Languages.Add(new LanguageInfo(lang)); |
|||
} |
|||
|
|||
return new AbpCultureMenuItemUrlProvider( |
|||
httpContextAccessor, localizationOptions, MsOptions.Create(abpLocOptions), new MenuItemCulturePrefixHelper()); |
|||
} |
|||
|
|||
private static ApplicationMenu CreateMenuWithItems(params string[] urls) |
|||
{ |
|||
var menu = new ApplicationMenu("TestMenu"); |
|||
for (var i = 0; i < urls.Length; i++) |
|||
{ |
|||
menu.AddItem(new ApplicationMenuItem($"Item{i}", $"Item {i}", url: urls[i])); |
|||
} |
|||
return menu; |
|||
} |
|||
|
|||
} |
|||
@ -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<HttpResponseMessage> 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); |
|||
} |
|||
} |
|||
@ -0,0 +1,259 @@ |
|||
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_Replace_Culture_When_Only_Culture_Param_Present() |
|||
{ |
|||
// culture= and ui-culture= are now handled independently
|
|||
var context = new QueryStringCultureReplacementContext( |
|||
new DefaultHttpContext(), new RequestCulture("en"), "/?culture=tr"); |
|||
await _replacement.ReplaceAsync(context); |
|||
context.ReturnUrl.ShouldBe("/?culture=en"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Replace_UiCulture_When_Only_UiCulture_Param_Present() |
|||
{ |
|||
var context = new QueryStringCultureReplacementContext( |
|||
new DefaultHttpContext(), new RequestCulture("en"), "/?ui-culture=tr"); |
|||
await _replacement.ReplaceAsync(context); |
|||
context.ReturnUrl.ShouldBe("/?ui-culture=en"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Support_Numeric_Region_Culture_Tag() |
|||
{ |
|||
// es-419 (Latin America Spanish) contains a digit — previously the regex
|
|||
// [A-Za-z-]+ would not match it, leaving the query string unreplaced.
|
|||
var context = new QueryStringCultureReplacementContext( |
|||
new DefaultHttpContext(), |
|||
new RequestCulture("es-419", "es-419"), |
|||
"/home?culture=tr&ui-culture=tr"); |
|||
await _replacement.ReplaceAsync(context); |
|||
context.ReturnUrl.ShouldBe("/home?culture=es-419&ui-culture=es-419"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Replace_Only_First_Culture_Occurrence_In_Path() |
|||
{ |
|||
// /en/products/en/details — the second "/en" is part of the path content,
|
|||
// not a culture prefix, and must not be replaced.
|
|||
var context = CreateContext("en", "tr", "/en/products/en/details"); |
|||
await _replacement.ReplaceAsync(context); |
|||
context.ReturnUrl.ShouldBe("/tr/products/en/details"); |
|||
} |
|||
|
|||
private static QueryStringCultureReplacementContext CreateContext( |
|||
string currentCulture, string targetCulture, string returnUrl) |
|||
{ |
|||
return new QueryStringCultureReplacementContext( |
|||
new DefaultHttpContext(), |
|||
new RequestCulture(targetCulture), |
|||
returnUrl, |
|||
currentCulture); |
|||
} |
|||
} |
|||
@ -0,0 +1,48 @@ |
|||
using System.Net; |
|||
using System.Threading.Tasks; |
|||
using Shouldly; |
|||
using Volo.Abp.AspNetCore.App; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.Localization; |
|||
|
|||
public class RouteBasedCultureApiRouting_Tests : AspNetCoreMvcTestBase |
|||
{ |
|||
[Fact] |
|||
public async Task Api_Route_Should_Not_Be_Intercepted_By_Culture_Route() |
|||
{ |
|||
var response = await GetResponseAsync("api/json-result-test/json-result-action"); |
|||
response.StatusCode.ShouldBe(HttpStatusCode.OK); |
|||
response.Content.Headers.ContentType!.MediaType.ShouldBe("application/json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Controller_Should_Work_With_Culture_Prefix() |
|||
{ |
|||
var result = await GetResponseAsStringAsync( |
|||
"/tr" + GetUrl<SimpleController>(nameof(SimpleController.Index))); |
|||
result.ShouldBe("Index-Result"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Controller_Should_Work_Without_Culture_Prefix() |
|||
{ |
|||
var result = await GetResponseAsStringAsync( |
|||
GetUrl<SimpleController>(nameof(SimpleController.Index))); |
|||
result.ShouldBe("Index-Result"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task RazorPage_Should_Work_With_Culture_Prefix() |
|||
{ |
|||
var response = await GetResponseAsync("/tr/Auditing/AuditTestPage"); |
|||
response.StatusCode.ShouldBe(HttpStatusCode.OK); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task RazorPage_Should_Work_Without_Culture_Prefix() |
|||
{ |
|||
var response = await GetResponseAsync("/Auditing/AuditTestPage"); |
|||
response.StatusCode.ShouldBe(HttpStatusCode.OK); |
|||
} |
|||
} |
|||
@ -0,0 +1,103 @@ |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Components; |
|||
using Shouldly; |
|||
using Volo.Abp.AspNetCore.Components.WebAssembly; |
|||
using Volo.Abp.Localization; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.Localization; |
|||
|
|||
public class RouteBasedCultureNavigationHelper_Tests |
|||
{ |
|||
private static readonly IEnumerable<LanguageInfo> AllLanguages = new[] |
|||
{ |
|||
new LanguageInfo("en"), |
|||
new LanguageInfo("tr"), |
|||
new LanguageInfo("zh-Hans"), |
|||
}; |
|||
|
|||
private readonly RouteBasedCultureNavigationHelper _helper = new(); |
|||
|
|||
[Fact] |
|||
public async Task Should_Replace_Culture_In_Simple_Path() |
|||
{ |
|||
var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr/home"); |
|||
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages); |
|||
nav.LastNavigatedUri.ShouldBe("https://example.com/en/home"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Replace_Culture_When_No_Path_After_Culture() |
|||
{ |
|||
var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr"); |
|||
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages); |
|||
nav.LastNavigatedUri.ShouldBe("https://example.com/en"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Replace_Culture_When_Query_String_Follows_Culture_Directly() |
|||
{ |
|||
// Regression: "tr?x=1" was being treated as a single segment "tr?x=1"
|
|||
// instead of culture="tr" + suffix="?x=1".
|
|||
var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr?x=1"); |
|||
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages); |
|||
nav.LastNavigatedUri.ShouldBe("https://example.com/en?x=1"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Replace_Culture_When_Fragment_Follows_Culture_Directly() |
|||
{ |
|||
var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr#section"); |
|||
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages); |
|||
nav.LastNavigatedUri.ShouldBe("https://example.com/en#section"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Replace_Culture_Preserving_Path_Query_And_Fragment() |
|||
{ |
|||
var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr/about?ref=main#top"); |
|||
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("zh-Hans"), AllLanguages); |
|||
nav.LastNavigatedUri.ShouldBe("https://example.com/zh-Hans/about?ref=main#top"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Prepend_Culture_When_No_Existing_Culture_Prefix() |
|||
{ |
|||
var nav = new TestNavigationManager("https://example.com/", "https://example.com/identity/users"); |
|||
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("zh-Hans"), AllLanguages); |
|||
nav.LastNavigatedUri.ShouldBe("https://example.com/zh-Hans/identity/users"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Prepend_Culture_When_At_Root() |
|||
{ |
|||
var nav = new TestNavigationManager("https://example.com/", "https://example.com/"); |
|||
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("tr"), AllLanguages); |
|||
nav.LastNavigatedUri.ShouldBe("https://example.com/tr/"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Navigate_When_Target_Culture_Matches_Current() |
|||
{ |
|||
var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr/home"); |
|||
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("tr"), AllLanguages); |
|||
// Already on /tr/home — no navigation should occur
|
|||
nav.LastNavigatedUri.ShouldBeNull(); |
|||
} |
|||
|
|||
private sealed class TestNavigationManager : NavigationManager |
|||
{ |
|||
public string? LastNavigatedUri { get; private set; } |
|||
|
|||
public TestNavigationManager(string baseUri, string uri) |
|||
{ |
|||
Initialize(baseUri, uri); |
|||
} |
|||
|
|||
protected override void NavigateToCore(string uri, bool forceLoad) |
|||
{ |
|||
LastNavigatedUri = uri; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,156 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Globalization; |
|||
using System.Threading.Tasks; |
|||
using NSubstitute; |
|||
using Shouldly; |
|||
using Volo.Abp.AspNetCore.Components.WebAssembly; |
|||
using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations; |
|||
using Volo.Abp.AspNetCore.Mvc.Client; |
|||
using Volo.Abp.Localization; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.Localization; |
|||
|
|||
public class RouteBasedCultureUrlHelper_Tests |
|||
{ |
|||
private readonly ICachedApplicationConfigurationClient _configClient; |
|||
private readonly RouteBasedCultureUrlHelper _helper; |
|||
private readonly ApplicationConfigurationDto _config; |
|||
|
|||
public RouteBasedCultureUrlHelper_Tests() |
|||
{ |
|||
_config = new ApplicationConfigurationDto |
|||
{ |
|||
Localization = new ApplicationLocalizationConfigurationDto |
|||
{ |
|||
UseRouteBasedCulture = true, |
|||
Languages = new List<LanguageInfo> |
|||
{ |
|||
new LanguageInfo("en"), |
|||
new LanguageInfo("zh-Hans"), |
|||
new LanguageInfo("tr"), |
|||
new LanguageInfo("es-419"), |
|||
} |
|||
} |
|||
}; |
|||
|
|||
_configClient = Substitute.For<ICachedApplicationConfigurationClient>(); |
|||
_configClient.GetAsync().Returns(_config); |
|||
|
|||
_helper = new RouteBasedCultureUrlHelper(_configClient); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("https://auth-server.example.com/connect/authorize")] |
|||
[InlineData("http://example.com/login")] |
|||
public async Task Should_Not_Modify_Absolute_Urls(string url) |
|||
{ |
|||
using var _ = CultureScope("zh-Hans"); |
|||
var result = await _helper.PrependCulturePrefixAsync(url); |
|||
result.ShouldBe(url); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Modify_Protocol_Relative_Url() |
|||
{ |
|||
using var _ = CultureScope("zh-Hans"); |
|||
var result = await _helper.PrependCulturePrefixAsync("//cdn.example.com/asset.js"); |
|||
result.ShouldBe("//cdn.example.com/asset.js"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Prepend_Culture_To_Root_Relative_Url() |
|||
{ |
|||
using var _ = CultureScope("zh-Hans"); |
|||
var result = await _helper.PrependCulturePrefixAsync("/account/manage-profile"); |
|||
result.ShouldBe("/zh-Hans/account/manage-profile"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Prepend_Culture_To_Tilde_Slash_Url() |
|||
{ |
|||
using var _ = CultureScope("tr"); |
|||
var result = await _helper.PrependCulturePrefixAsync("~/account/manage-profile"); |
|||
result.ShouldBe("~/tr/account/manage-profile"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Prepend_Culture_To_Bare_Relative_Url() |
|||
{ |
|||
// Default auth URLs like "authentication/login" have no leading slash.
|
|||
using var _ = CultureScope("zh-Hans"); |
|||
var result = await _helper.PrependCulturePrefixAsync("authentication/login"); |
|||
result.ShouldBe("zh-Hans/authentication/login"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Modify_Url_When_Feature_Disabled() |
|||
{ |
|||
_config.Localization.UseRouteBasedCulture = false; |
|||
using var _ = CultureScope("zh-Hans"); |
|||
var result = await _helper.PrependCulturePrefixAsync("/home"); |
|||
result.ShouldBe("/home"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Modify_Url_When_Culture_Not_In_Language_List() |
|||
{ |
|||
using var _ = CultureScope("fr"); |
|||
var result = await _helper.PrependCulturePrefixAsync("/home"); |
|||
result.ShouldBe("/home"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Return_Empty_String_Unchanged() |
|||
{ |
|||
var result = await _helper.PrependCulturePrefixAsync(string.Empty); |
|||
result.ShouldBe(string.Empty); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Support_Numeric_Region_Culture_Tag() |
|||
{ |
|||
using var _ = CultureScope("es-419"); |
|||
var result = await _helper.PrependCulturePrefixAsync("/home"); |
|||
result.ShouldBe("/es-419/home"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Be_Idempotent_On_Root_Relative_Url() |
|||
{ |
|||
using var _ = CultureScope("zh-Hans"); |
|||
var result = await _helper.PrependCulturePrefixAsync("/zh-Hans/account/manage-profile"); |
|||
result.ShouldBe("/zh-Hans/account/manage-profile"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Be_Idempotent_On_Tilde_Slash_Url() |
|||
{ |
|||
using var _ = CultureScope("tr"); |
|||
var result = await _helper.PrependCulturePrefixAsync("~/tr/account/manage-profile"); |
|||
result.ShouldBe("~/tr/account/manage-profile"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Be_Idempotent_On_Bare_Relative_Url() |
|||
{ |
|||
using var _ = CultureScope("zh-Hans"); |
|||
var result = await _helper.PrependCulturePrefixAsync("zh-Hans/authentication/login"); |
|||
result.ShouldBe("zh-Hans/authentication/login"); |
|||
} |
|||
|
|||
private static IDisposable CultureScope(string cultureName) |
|||
{ |
|||
var previous = CultureInfo.CurrentCulture; |
|||
CultureInfo.CurrentCulture = new CultureInfo(cultureName); |
|||
return new DelegateDisposable(() => CultureInfo.CurrentCulture = previous); |
|||
} |
|||
|
|||
private sealed class DelegateDisposable : IDisposable |
|||
{ |
|||
private readonly System.Action _onDispose; |
|||
public DelegateDisposable(System.Action onDispose) => _onDispose = onDispose; |
|||
public void Dispose() => _onDispose(); |
|||
} |
|||
} |
|||
@ -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<AbpRequestLocalizationOptions>(options => |
|||
{ |
|||
options.UseRouteBasedCulture = true; |
|||
}); |
|||
|
|||
Configure<AbpLocalizationOptions>(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); |
|||
}); |
|||
}); |
|||
} |
|||
} |
|||
@ -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<RouteBasedCultureTestModule>(); |
|||
_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"); |
|||
} |
|||
} |
|||
@ -0,0 +1,157 @@ |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Shouldly; |
|||
using Volo.Abp.Modularity; |
|||
using Volo.Abp.Testing; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Autofac; |
|||
|
|||
public class AutofacRegistration_Tests : AbpIntegratedTest<AutofacRegistration_Tests.TestModule> |
|||
{ |
|||
protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) |
|||
{ |
|||
options.UseAutofac(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Resolve_AnyKey_Keyed_Service_With_Any_Key() |
|||
{ |
|||
// AnyKey registration should be resolvable with any key value.
|
|||
var serviceWithKeyA = GetRequiredKeyedService<IAnyKeyService>("keyA"); |
|||
var serviceWithKeyB = GetRequiredKeyedService<IAnyKeyService>("keyB"); |
|||
var serviceWithKeyC = GetRequiredKeyedService<IAnyKeyService>(42); |
|||
|
|||
serviceWithKeyA.ShouldNotBeNull(); |
|||
serviceWithKeyB.ShouldNotBeNull(); |
|||
serviceWithKeyC.ShouldNotBeNull(); |
|||
|
|||
serviceWithKeyA.ShouldBeOfType<AnyKeyServiceImpl>(); |
|||
serviceWithKeyB.ShouldBeOfType<AnyKeyServiceImpl>(); |
|||
serviceWithKeyC.ShouldBeOfType<AnyKeyServiceImpl>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Pass_Correct_Key_To_Keyed_Factory() |
|||
{ |
|||
var serviceA = GetRequiredKeyedService<IKeyedFactoryService>("alpha"); |
|||
var serviceB = GetRequiredKeyedService<IKeyedFactoryService>("beta"); |
|||
|
|||
serviceA.Key.ShouldBe("alpha"); |
|||
serviceB.Key.ShouldBe("beta"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Not_Dispose_Instance_Registration_When_Scope_Disposed() |
|||
{ |
|||
// Resolve the pre-registered singleton instance.
|
|||
var instance = GetRequiredKeyedService<IDisposableInstance>("instance"); |
|||
instance.ShouldNotBeNull(); |
|||
instance.IsDisposed.ShouldBeFalse(); |
|||
|
|||
// The same instance should be returned from a child scope.
|
|||
using (var scope = ServiceProvider.CreateScope()) |
|||
{ |
|||
var scopedInstance = scope.ServiceProvider.GetRequiredKeyedService<IDisposableInstance>("instance"); |
|||
scopedInstance.ShouldBeSameAs(instance); |
|||
} |
|||
|
|||
// After the scope is disposed, the singleton instance should still be alive.
|
|||
instance.IsDisposed.ShouldBeFalse(); |
|||
|
|||
// It should also be the same static instance registered in the module.
|
|||
instance.ShouldBeSameAs(TestModule.DisposableInstanceForTest); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Resolve_Standard_Keyed_Services() |
|||
{ |
|||
var big = GetRequiredKeyedService<ITypedCache>("big"); |
|||
var small = GetRequiredKeyedService<ITypedCache>("small"); |
|||
|
|||
big.ShouldBeOfType<BigTypedCache>(); |
|||
small.ShouldBeOfType<SmallTypedCache>(); |
|||
|
|||
big.Get("test").ShouldBe("big:test"); |
|||
small.Get("test").ShouldBe("small:test"); |
|||
} |
|||
|
|||
[DependsOn(typeof(AbpAutofacModule))] |
|||
public class TestModule : AbpModule |
|||
{ |
|||
public static DisposableInstance DisposableInstanceForTest { get; } = new(); |
|||
|
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
// AnyKey registration: this service can be resolved with any key.
|
|||
context.Services.AddKeyedTransient<IAnyKeyService, AnyKeyServiceImpl>( |
|||
Microsoft.Extensions.DependencyInjection.KeyedService.AnyKey); |
|||
|
|||
// Keyed factory registration: the factory receives the actual key used for resolution.
|
|||
context.Services.Add(ServiceDescriptor.KeyedTransient<IKeyedFactoryService>( |
|||
Microsoft.Extensions.DependencyInjection.KeyedService.AnyKey, |
|||
(sp, key) => new KeyedFactoryServiceImpl(key))); |
|||
|
|||
// Instance registration with keyed service (ExternallyOwned should prevent Autofac from disposing it).
|
|||
context.Services.AddKeyedSingleton<IDisposableInstance>("instance", DisposableInstanceForTest); |
|||
|
|||
// Standard keyed type registrations.
|
|||
context.Services.AddKeyedTransient<ITypedCache, BigTypedCache>("big"); |
|||
context.Services.AddKeyedTransient<ITypedCache, SmallTypedCache>("small"); |
|||
} |
|||
} |
|||
|
|||
public interface IAnyKeyService |
|||
{ |
|||
} |
|||
|
|||
public class AnyKeyServiceImpl : IAnyKeyService |
|||
{ |
|||
} |
|||
|
|||
public interface IKeyedFactoryService |
|||
{ |
|||
object Key { get; } |
|||
} |
|||
|
|||
public class KeyedFactoryServiceImpl : IKeyedFactoryService |
|||
{ |
|||
public object Key { get; } |
|||
|
|||
public KeyedFactoryServiceImpl(object key) |
|||
{ |
|||
Key = key; |
|||
} |
|||
} |
|||
|
|||
public interface IDisposableInstance |
|||
{ |
|||
bool IsDisposed { get; } |
|||
} |
|||
|
|||
public class DisposableInstance : IDisposableInstance, IDisposable |
|||
{ |
|||
public bool IsDisposed { get; private set; } |
|||
|
|||
public void Dispose() |
|||
{ |
|||
IsDisposed = true; |
|||
} |
|||
} |
|||
|
|||
public interface ITypedCache |
|||
{ |
|||
string Get(string key); |
|||
} |
|||
|
|||
public class BigTypedCache : ITypedCache |
|||
{ |
|||
public string Get(string key) => $"big:{key}"; |
|||
} |
|||
|
|||
public class SmallTypedCache : ITypedCache |
|||
{ |
|||
public string Get(string key) => $"small:{key}"; |
|||
} |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
using System; |
|||
using System.Runtime.InteropServices; |
|||
using System.Threading.Tasks; |
|||
using Shouldly; |
|||
using Volo.Abp.Cli.Utils; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Cli.Utils; |
|||
|
|||
public class CmdHelper_Tests : AbpCliTestBase |
|||
{ |
|||
private readonly ICmdHelper _cmdHelper; |
|||
|
|||
public CmdHelper_Tests() |
|||
{ |
|||
_cmdHelper = GetRequiredService<ICmdHelper>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task RunCmdAndGetOutput_Should_Not_Deadlock_With_Large_Stdout_And_Stderr() |
|||
{ |
|||
// Reproduces the deadlock bug where sequential ReadToEnd() on stdout then stderr
|
|||
// would block indefinitely when both pipe buffers (~64 KB on Linux/macOS, ~4 KB on
|
|||
// Windows) filled up simultaneously. The process was blocked writing to stderr while
|
|||
// the caller was blocked waiting for stdout to close — a classic pipe deadlock.
|
|||
//
|
|||
// The fix reads both streams concurrently via ReadToEndAsync + Task.WhenAll, which
|
|||
// drains both pipes at the same time and avoids the deadlock.
|
|||
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) |
|||
? @"for /L %i in (1,1,3000) do @(echo stdout-line-%i & echo stderr-line-%i 1>&2)" |
|||
: "for i in $(seq 1 5000); do echo stdout-line-$i; echo stderr-line-$i >&2; done"; |
|||
|
|||
string output = null; |
|||
var cmdTask = Task.Run(() => output = _cmdHelper.RunCmdAndGetOutput(command)); |
|||
var completed = await Task.WhenAny(cmdTask, Task.Delay(TimeSpan.FromSeconds(10))); |
|||
|
|||
// The original sequential code deadlocked here; 10 s is a generous upper bound.
|
|||
(completed == cmdTask).ShouldBeTrue( |
|||
"RunCmdAndGetOutput should not deadlock when both stdout and stderr produce large output"); |
|||
|
|||
await cmdTask; |
|||
|
|||
output.ShouldNotBeNullOrWhiteSpace(); |
|||
output.ShouldContain("stdout-line-"); |
|||
output.ShouldContain("stderr-line-"); |
|||
} |
|||
} |
|||
@ -1,25 +1,22 @@ |
|||
@inject NavigationManager Navigation |
|||
@using Volo.Abp.DependencyInjection |
|||
@using Volo.Abp.AspNetCore.Components.Web.BasicTheme.Themes.Basic |
|||
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication |
|||
@using Volo.Abp.AspNetCore.Components.WebAssembly.Theming |
|||
@using Microsoft.Extensions.Options |
|||
@using Volo.Abp.AspNetCore.Components.WebAssembly |
|||
@using Volo.Abp.AspNetCore.Components.Web |
|||
@inherits RedirectToLogin |
|||
@attribute [ExposeServices(typeof(RedirectToLogin))] |
|||
@attribute [Dependency(ReplaceServices = true)] |
|||
@inject IOptions<AuthenticationOptions> AuthenticationOptions |
|||
@inject NavigationManager Navigation |
|||
@inject IOptions<AuthenticationOptions> AuthOptions |
|||
@inject IRouteBasedCultureUrlHelper CultureUrlHelper |
|||
@inject IOptions<AbpAspNetCoreComponentsWebOptions> AbpAspNetCoreComponentsWebOptions |
|||
|
|||
@code { |
|||
protected override void OnInitialized() |
|||
protected override void OnInitialized() { } |
|||
|
|||
protected override Task OnInitializedAsync() |
|||
{ |
|||
if (AbpAspNetCoreComponentsWebOptions.Value.IsBlazorWebApp) |
|||
{ |
|||
Navigation.NavigateTo(AuthenticationOptions.Value.LoginUrl, forceLoad: true); |
|||
} |
|||
else |
|||
{ |
|||
Navigation.NavigateToLogin(AuthenticationOptions.Value.LoginUrl); |
|||
} |
|||
return CultureAwareRedirectToLoginHelper.RedirectAsync(Navigation, AuthOptions.Value.LoginUrl, CultureUrlHelper, AbpAspNetCoreComponentsWebOptions); |
|||
} |
|||
} |
|||
|
|||