Browse Source

Merge branch 'dev' into auto-merge/rel-10-2/4417

pull/25048/head
Ma Liming 7 months ago
committed by GitHub
parent
commit
c8a9ece4f5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      Directory.Packages.props
  2. 4
      common.props
  3. 242
      docs/en/Blog-Posts/2026-02-25 v10_2_Preview/POST.md
  4. BIN
      docs/en/Blog-Posts/2026-02-25 v10_2_Preview/ai-management-openai-anythingllm.png
  5. BIN
      docs/en/Blog-Posts/2026-02-25 v10_2_Preview/chat-playground.png
  6. BIN
      docs/en/Blog-Posts/2026-02-25 v10_2_Preview/cover-image.png
  7. BIN
      docs/en/Blog-Posts/2026-02-25 v10_2_Preview/file-management-rba.png
  8. BIN
      docs/en/Blog-Posts/2026-02-25 v10_2_Preview/mcp-servers.png
  9. BIN
      docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-embedder.png
  10. BIN
      docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-file-upload.png
  11. BIN
      docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-vector-store.png
  12. BIN
      docs/en/Blog-Posts/2026-02-25 v10_2_Preview/studio-switch-to-preview.png
  13. BIN
      docs/en/Blog-Posts/2026-02-25 v10_2_Preview/test-connection.png
  14. 227
      docs/en/Community-Articles/2026-03-09-Automate-Localhost-Access-for-Expo/POST.md
  15. 201
      docs/en/Community-Articles/2026-03-09-Resource-Based-Authorization-in-ABP-Framework/POST.md
  16. BIN
      docs/en/Community-Articles/2026-03-09-Resource-Based-Authorization-in-ABP-Framework/add-permission-dialog.png
  17. BIN
      docs/en/Community-Articles/2026-03-09-Resource-Based-Authorization-in-ABP-Framework/book-list.png
  18. BIN
      docs/en/Community-Articles/2026-03-09-Resource-Based-Authorization-in-ABP-Framework/cover.jpeg
  19. BIN
      docs/en/Community-Articles/2026-03-09-Resource-Based-Authorization-in-ABP-Framework/delete-permission-confirm.png
  20. BIN
      docs/en/Community-Articles/2026-03-09-Resource-Based-Authorization-in-ABP-Framework/resource-permission-dialog.png
  21. BIN
      docs/en/Community-Articles/2026-03-09-Resource-Based-Authorization-in-ABP-Framework/update-permission-dialog.png
  22. 188
      docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md
  23. BIN
      docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/cover.jpeg
  24. 4
      docs/en/docs-nav.json
  25. 492
      docs/en/framework/infrastructure/operation-rate-limiting.md
  26. 142
      docs/en/framework/ui/angular/pwa-configuration.md
  27. 4
      docs/en/low-code/custom-endpoints.md
  28. 94
      docs/en/low-code/scripting-api.md
  29. 10
      docs/en/package-version-changes.md
  30. BIN
      docs/en/tutorials/modular-crm/images/abp-studio-add-new-standard-module-ui-dialog-blazor-webapp.png
  31. BIN
      docs/en/tutorials/modular-crm/images/abp-studio-add-package-reference-dialog-3-blazor-webapp.png
  32. BIN
      docs/en/tutorials/modular-crm/images/abp-studio-catalog-module-expanded-in-solution-explorer-blazor-webapp.png
  33. BIN
      docs/en/tutorials/modular-crm/images/abp-studio-imports-and-dependencies-v2-blazor-webapp.png
  34. BIN
      docs/en/tutorials/modular-crm/images/abp-studio-install-module-dialog-blazor-webapp.png
  35. BIN
      docs/en/tutorials/modular-crm/images/abp-studio-install-module-dialog-for-ordering-v2-blazor-webapp.png
  36. BIN
      docs/en/tutorials/modular-crm/images/abp-studio-modular-crm-with-standard-module-blazor-webapp.png
  37. BIN
      docs/en/tutorials/modular-crm/images/abp-studio-module-installation-dialog-for-catalog-blazor-webapp.png
  38. BIN
      docs/en/tutorials/modular-crm/images/catalog-module-vs-code-blazor-webapp.png
  39. BIN
      docs/en/tutorials/modular-crm/images/vscode-catalog-index-razor-blazor-webapp.png
  40. 7
      docs/en/tutorials/modular-crm/index.md
  41. 15
      docs/en/tutorials/modular-crm/part-01.md
  42. 15
      docs/en/tutorials/modular-crm/part-02.md
  43. 90
      docs/en/tutorials/modular-crm/part-03.md
  44. 23
      docs/en/tutorials/modular-crm/part-04.md
  45. 122
      docs/en/tutorials/modular-crm/part-05.md
  46. 53
      docs/en/tutorials/modular-crm/part-06.md
  47. 15
      docs/en/tutorials/modular-crm/part-07.md
  48. 9
      docs/en/tutorials/modular-crm/part-08.md
  49. 2
      framework/Volo.Abp.slnx
  50. 2
      framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/AbpAspNetCoreAbstractionsModule.cs
  51. 7
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController.cs
  52. 22
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/IXmlDocumentationProvider.cs
  53. 231
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs
  54. 224
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs
  55. 7
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ProxyScripting/AbpServiceProxyScriptController.cs
  56. 8
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs
  57. 2
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ApplicationApiDescriptionModelRequestDto.cs
  58. 16
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ControllerApiDescriptionModel.cs
  59. 4
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IApiDescriptionModelProvider.cs
  60. 6
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/MethodParameterApiDescriptionModel.cs
  61. 6
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ParameterApiDescriptionModel.cs
  62. 6
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs
  63. 2
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs
  64. 8
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs
  65. 4
      framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManager.cs
  66. 7
      framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManagerCache.cs
  67. 13
      framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManager.cs
  68. 31
      framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManagerCache.cs
  69. 3
      framework/src/Volo.Abp.OperationRateLimiting/FodyWeavers.xml
  70. 32
      framework/src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj
  71. 14
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs
  72. 42
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingModule.cs
  73. 20
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs
  74. 8
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingResource.cs
  75. 14
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/IOperationRateLimitingChecker.cs
  76. 277
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs
  77. 38
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingCheckerExtensions.cs
  78. 33
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs
  79. 24
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingResult.cs
  80. 20
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs
  81. 48
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs
  82. 68
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/DefaultOperationRateLimitingFormatter.cs
  83. 8
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/IOperationRateLimitingFormatter.cs
  84. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json
  85. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json
  86. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json
  87. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json
  88. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json
  89. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json
  90. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json
  91. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json
  92. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json
  93. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json
  94. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json
  95. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json
  96. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json
  97. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json
  98. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json
  99. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json
  100. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json

10
Directory.Packages.props

@ -132,11 +132,11 @@
<PackageVersion Include="NUglify" Version="1.21.17" /> <PackageVersion Include="NUglify" Version="1.21.17" />
<PackageVersion Include="Nullable" Version="1.3.1" /> <PackageVersion Include="Nullable" Version="1.3.1" />
<PackageVersion Include="Octokit" Version="14.0.0" /> <PackageVersion Include="Octokit" Version="14.0.0" />
<PackageVersion Include="OpenIddict.Abstractions" Version="7.2.0" /> <PackageVersion Include="OpenIddict.Abstractions" Version="7.3.0" />
<PackageVersion Include="OpenIddict.Core" Version="7.2.0" /> <PackageVersion Include="OpenIddict.Core" Version="7.3.0" />
<PackageVersion Include="OpenIddict.Server.AspNetCore" Version="7.2.0" /> <PackageVersion Include="OpenIddict.Server.AspNetCore" Version="7.3.0" />
<PackageVersion Include="OpenIddict.Validation.AspNetCore" Version="7.2.0" /> <PackageVersion Include="OpenIddict.Validation.AspNetCore" Version="7.3.0" />
<PackageVersion Include="OpenIddict.Validation.ServerIntegration" Version="7.2.0" /> <PackageVersion Include="OpenIddict.Validation.ServerIntegration" Version="7.3.0" />
<PackageVersion Include="Oracle.EntityFrameworkCore" Version="10.23.26000" /> <PackageVersion Include="Oracle.EntityFrameworkCore" Version="10.23.26000" />
<PackageVersion Include="Polly" Version="8.6.3" /> <PackageVersion Include="Polly" Version="8.6.3" />
<PackageVersion Include="Polly.Extensions.Http" Version="3.0.0" /> <PackageVersion Include="Polly.Extensions.Http" Version="3.0.0" />

4
common.props

@ -1,8 +1,8 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Version>10.2.0-rc.2</Version> <Version>10.3.0-preview</Version>
<LeptonXVersion>5.2.0-rc.2</LeptonXVersion> <LeptonXVersion>5.3.0-preview</LeptonXVersion>
<NoWarn>$(NoWarn);CS1591;CS0436</NoWarn> <NoWarn>$(NoWarn);CS1591;CS0436</NoWarn>
<PackageIconUrl>https://abp.io/assets/abp_nupkg.png</PackageIconUrl> <PackageIconUrl>https://abp.io/assets/abp_nupkg.png</PackageIconUrl>
<PackageProjectUrl>https://abp.io/</PackageProjectUrl> <PackageProjectUrl>https://abp.io/</PackageProjectUrl>

242
docs/en/Blog-Posts/2026-02-25 v10_2_Preview/POST.md

@ -0,0 +1,242 @@
# ABP Platform 10.2 RC Has Been Released
We are happy to release [ABP](https://abp.io) version **10.2 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.2! Thanks to you in advance.
## Get Started with the 10.2 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:
![studio-switch-to-preview.png](studio-switch-to-preview.png)
## Migration Guide
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: [ABP Version 10.2 Migration Guide](https://abp.io/docs/10.2/release-info/migration-guides/abp-10-2).
## What's New with ABP v10.2?
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:
- Multi-Tenant Account Usage: Shared User Accounts
- Prevent Privilege Escalation: Assignment Restrictions for Roles and Permissions
- `ClientResourcePermissionValueProvider` for OAuth/OpenIddict
- Angular: Hybrid Localization Support
- Angular: Extensible Table Row Detail
- Angular: CMS Kit Module Features
- Blazor: Upgrade to Blazorise 2.0
- Identity: Single Active Token Providers
- TickerQ Package Upgrade to 10.1.1
- AI Management: MCP (Model Context Protocol) Support
- AI Management: RAG with File Upload
- AI Management: OpenAI-Compatible Chat Endpoint
- File Management: Resource-Based Authorization
### Multi-Tenant Account Usage: Shared User Accounts
ABP v10.2 introduces **Shared User Accounts**: a single user account can belong to multiple tenants, and the user can choose or switch the active tenant when signing in. This enables a "one account, multiple tenants" experience — for example, inviting the same email address into multiple tenants.
When you use Shared User Accounts:
- Username/email uniqueness becomes **global** (Host + all tenants)
- Users are prompted to select the tenant at login if they belong to multiple tenants
- Users can switch between tenants using the tenant switcher in the user menu
- Tenant administrators can invite existing or new users to join a tenant
Enable shared accounts by configuring `UserSharingStrategy`:
```csharp
Configure<AbpMultiTenancyOptions>(options =>
{
options.IsEnabled = true;
options.UserSharingStrategy = TenantUserSharingStrategy.Shared;
});
```
> See the [Shared User Accounts](https://abp.io/docs/10.2/modules/account/shared-user-accounts) documentation for details.
### Prevent Privilege Escalation: Assignment Restrictions for Roles and Permissions
ABP v10.2 implements a unified **privilege escalation prevention** model to address security vulnerabilities where users could assign themselves or others roles or permissions they do not possess.
**Role Assignment Restriction:** Users can only assign or remove roles they currently have. Users cannot add new roles to themselves (removal only) and cannot assign or remove roles they do not possess.
**Permission Grant/Revoke Authorization:** Users can only grant or revoke permissions they currently have. Validation applies to both grant and revoke operations.
**Incremental Permission Protection:** When updating user or role permissions, permissions the current user does not have are treated as non-editable and are preserved as-is during updates.
Users with the `admin` role can assign any role and grant/revoke any permission. All validations are enforced on the backend — the UI is not a security boundary.
> See [#24775](https://github.com/abpframework/abp/pull/24775) for more details.
### `ClientResourcePermissionValueProvider` for OAuth/OpenIddict
ABP v10.2 adds **ClientResourcePermissionValueProvider**, extending resource-based authorization to OAuth clients. When using IdentityServer or OpenIddict, clients can now have resource permissions aligned with the standard user and role permission model.
This allows you to control which OAuth clients can access which resources, providing fine-grained authorization for API consumers. The implementation integrates with ABP's existing resource permission infrastructure.
> See [#24515](https://github.com/abpframework/abp/pull/24515) for more details.
### Angular: Hybrid Localization Support
ABP v10.2 introduces **Hybrid Localization** for Angular applications, combining server-side and client-side localization strategies. This gives you flexibility in how translations are loaded and resolved — you can use server-provided localization, client-side fallbacks, or a mix of both.
This feature is useful when you want to reduce initial load time, support offline scenarios, or have environment-specific localization behavior. The Angular packages have been updated to support the hybrid approach seamlessly.
> See the [Hybrid Localization](https://abp.io/docs/10.2/framework/ui/angular/hybrid-localization) documentation and [#24731](https://github.com/abpframework/abp/pull/24731).
### Angular: Extensible Table Row Detail
ABP v10.2 adds the **ExtensibleTableRowDetailComponent** for expandable row details in extensible tables. You can now display additional information for each row in a collapsible detail section.
The feature supports row detail templates via both direct input and content child component. It adds toggle logic and emits `rowDetailToggle` events, making it easy to customize the behavior and appearance of expandable rows in your data tables.
> See [#24636](https://github.com/abpframework/abp/pull/24636) for more details.
### Angular: CMS Kit Module Features
ABP v10.2 brings **CMS Kit features to Angular**, completing the cross-platform UI coverage for the CMS Kit module. The Angular implementation includes: Blogs, Blog Posts, Comments, Menus, Pages, Tags, Global Resources, and CMS Settings.
Together with the CMS Kit Pro Angular implementation (FAQ, Newsletters, Page Feedbacks, Polls, Url forwarding), ABP now provides full Angular UI coverage for both the open-source CMS Kit and CMS Kit Pro modules.
> See [#24234](https://github.com/abpframework/abp/pull/24234) for more details.
### Blazor: Upgrade to Blazorise 2.0
ABP v10.2 upgrades the [Blazorise](https://blazorise.com/) library to **version 2.0** for Blazor UI. If you are upgrading your project to v10.2 RC, please ensure that all Blazorise-related packages are updated to v2.0 in your application.
Blazorise 2.0 includes various improvements and changes. Please refer to the [Blazorise 2.0 Release Notes](https://blazorise.com/news/release-notes/200) and the [ABP Blazorise 2.0 Migration Guide](https://abp.io/docs/10.2/release-info/migration-guides/blazorise-2-0-migration) for upgrade instructions.
> See [#24906](https://github.com/abpframework/abp/pull/24906) for more details.
### Identity: Single Active Token Providers
ABP v10.2 introduces a **single active token** policy for password reset, email confirmation, and change-email flows. Three new token providers are available: `AbpPasswordResetTokenProvider`, `AbpEmailConfirmationTokenProvider`, and `AbpChangeEmailTokenProvider`.
When a new token is generated, it invalidates any previously issued tokens for that purpose. This improves security by ensuring that only the most recently issued token is valid. Token lifespan can be customized via the respective options classes for each provider.
> See [#24926](https://github.com/abpframework/abp/pull/24926) for more details.
### TickerQ Package Upgrade to 10.1.1
**If you are using the TickerQ integration packages** (`Volo.Abp.TickerQ`, `Volo.Abp.BackgroundJobs.TickerQ`, or `Volo.Abp.BackgroundWorkers.TickerQ`), you need to apply breaking changes when upgrading to ABP 10.2. TickerQ has been upgraded from 2.5.3 to 10.1.1, which only targets .NET 10.0 and contains several API changes.
Key changes include:
- `UseAbpTickerQ` moved from `IApplicationBuilder` to `IHost` — use `context.GetHost().UseAbpTickerQ()` in your module
- Entity types renamed: `TimeTicker` → `TimeTickerEntity`, `CronTicker` → `CronTickerEntity`
- Scheduler and dashboard configuration APIs have changed
- New helpers: `context.GetHost()`, `GetWebApplication()`, `GetEndpointRouteBuilder()`
> **Important:** Do **not** resolve `IHost` from `context.ServiceProvider.GetRequiredService<IHost>()`. Always use `context.GetHost()`. See the [ABP Version 10.2 Migration Guide](https://abp.io/docs/10.2/release-info/migration-guides/abp-10-2) for the complete list of changes.
### AI Management: MCP (Model Context Protocol) Support
_This is a **PRO** feature available for ABP Commercial customers._
The [AI Management Module](https://abp.io/docs/10.2/modules/ai-management) now supports [MCP (Model Context Protocol)](https://modelcontextprotocol.io/), enabling AI workspaces to use external MCP servers as tools. MCP allows AI models to interact with external services, databases, APIs, and more through a standardized protocol.
![mcp-servers](mcp-servers.png)
You can create and manage MCP servers via the AI Management UI. Each MCP server supports one of the following transport types: **Stdio** (runs a local command), **SSE** (Server-Sent Events), or **StreamableHttp**. For HTTP-based transports, you can configure authentication (API Key, Bearer token, or custom headers). Once MCP servers are defined, you can associate them with workspaces. When a workspace has MCP servers associated, the AI model can invoke tools from those servers during chat conversations — tool calls and results are displayed in the chat interface.
You can test the connection to an MCP server after creating it to verify connectivity and list available tools before use:
![test-connection](test-connection.png)
When a workspace has MCP servers associated, the AI model can invoke tools from those servers during chat conversations. Tool calls and results are displayed in the chat interface.
![chat-playground](chat-playground.png)
> See the [AI Management documentation](https://abp.io/docs/10.2/modules/ai-management#mcp-servers) for details.
### AI Management: RAG with File Upload
_This is a **PRO** feature available for ABP Commercial customers._
The AI Management module supports **RAG (Retrieval-Augmented Generation)** with file upload, which enables workspaces to answer questions based on the content of uploaded documents. When RAG is configured, the AI model searches the uploaded documents for relevant information before generating a response.
To enable RAG, configure an **embedder** (e.g., OpenAI, Ollama) and a **vector store** (e.g., PgVector) on the workspace:
| Embedder | Vector Store |
| --- | --- |
| ![rag-embedder](rag-embedder.png) | ![rag-vector-store](rag-vector-store.png) |
You can then upload documents (PDF, Markdown, or text files, max 10 MB) through the workspace management UI. Uploaded documents are automatically processed — their content is chunked, embedded, and stored in the configured vector store:
![rag-file-upload](rag-file-upload.png)
When you ask questions in the chat interface, the AI model uses the uploaded documents as context for accurate, grounded responses.
> See the [AI Management — RAG with File Upload](https://abp.io/docs/10.2/modules/ai-management#rag-with-file-upload) documentation for configuration details.
### AI Management: OpenAI-Compatible Chat Endpoint
_This is a **PRO** feature available for ABP Commercial customers._
The AI Management module exposes an **OpenAI-compatible REST API** at the `/v1` path. This allows any application or tool that supports the OpenAI API format — such as [AnythingLLM](https://anythingllm.com/), [Open WebUI](https://openwebui.com/), [Dify](https://dify.ai/), or custom scripts using the OpenAI SDK — to connect directly to your AI Management instance.
**Example configuration from AnythingLLM**:
![anythingllm](ai-management-openai-anythingllm.png)
Each AI Management **workspace** appears as a selectable model in the client application. The workspace's configured AI provider handles the actual inference transparently. Available endpoints include `/v1/chat/completions`, `/v1/models`, `/v1/embeddings`, `/v1/files`, and more. All endpoints require authentication via a Bearer token in the `Authorization` header.
> See the [AI Management — OpenAI-Compatible API](https://abp.io/docs/10.2/modules/ai-management#openai-compatible-api) documentation for usage examples.
### File Management: Resource-Based Authorization
_This is a **PRO** feature available for ABP Commercial customers._
The **File Management Module** now supports **resource-based authorization**. You can control access to individual files and folders per user, role, or client. Permissions can be granted at the resource level via the UI, and the feature integrates with ABP's resource permission infrastructure.
![file-management-resource-based-authorization](file-management-rba.png)
This feature is **implemented for all three supported UIs: MVC/Razor Pages, Blazor, and Angular**, providing a consistent experience across your application regardless of the UI framework you use.
### Other Improvements and Enhancements
- **Angular signal APIs**: ABP Angular packages migrated to signal queries, output functions, and signal input functions for alignment with Angular 21 ([#24765](https://github.com/abpframework/abp/pull/24765), [#24766](https://github.com/abpframework/abp/pull/24766), [#24777](https://github.com/abpframework/abp/pull/24777)).
- **Angular Vitest**: ABP Angular templates now use Vitest as the default testing framework instead of Karma/Jasmine ([#24725](https://github.com/abpframework/abp/pull/24725)).
- **Ambient auditing**: Programmatic disable/enable of auditing via `IAuditingHelper.DisableAuditing()` and `IsAuditingEnabled()` ([#24718](https://github.com/abpframework/abp/pull/24718)).
- **Complex property auditing**: Entity History and ModifierId now support EF Core complex properties ([#24767](https://github.com/abpframework/abp/pull/24767)).
- **RabbitMQ correlation ID**: Correlation ID support added to RabbitMQ JobQueue for distributed tracing ([#24755](https://github.com/abpframework/abp/pull/24755)).
- **Concurrent config retrieval**: `MvcCachedApplicationConfigurationClient` now fetches configuration and localization concurrently for faster startup ([#24838](https://github.com/abpframework/abp/pull/24838)).
- **Environment localization fallback**: Angular can use `environment.defaultResourceName` when the backend does not provide it ([#24589](https://github.com/abpframework/abp/pull/24589)).
- **JS proxy namespace fix**: Resolved namespace mismatch for multi-segment company names in generated proxies ([#24877](https://github.com/abpframework/abp/pull/24877)).
- **Audit Logging max length**: Entity/property type full names increased to 512 characters to reduce truncation ([#24846](https://github.com/abpframework/abp/pull/24846)).
- **AI guidelines**: Cursor and Copilot AI guideline documents added for ABP development ([#24563](https://github.com/abpframework/abp/pull/24563), [#24593](https://github.com/abpframework/abp/pull/24593)).
## Community News
### New ABP Community Articles
As always, exciting articles have been contributed by the ABP community. I will highlight some of them here:
- [Enis Necipoğlu](https://abp.io/community/members/enisn) has published 2 new posts:
- [ABP Framework's Hidden Magic: Things That Just Work Without You Knowing](https://abp.io/community/articles/hidden-magic-things-that-just-work-without-you-knowing-vw6osmyt)
- [Implementing Multiple Global Query Filters with Entity Framework Core](https://abp.io/community/articles/implementing-multiple-global-query-filters-with-entity-ugnsmf6i)
- [Suhaib Mousa](https://abp.io/community/members/suhaib-mousa) has published 2 new posts:
- [.NET 11 Preview 1 Highlights: Faster Runtime, Smarter JIT, and AI-Ready Improvements](https://abp.io/community/articles/dotnet-11-preview-1-highlights-hspp3o5x)
- [TOON vs JSON for LLM Prompts in ABP: Token-Efficient Structured Context](https://abp.io/community/articles/toon-vs-json-b4rn2avd)
- [Fahri Gedik](https://abp.io/community/members/fahrigedik) has published 2 new posts:
- [Building a Multi-Agent AI System with A2A, MCP, and ADK in .NET](https://abp.io/community/articles/building-a-multiagent-ai-system-with-a2a-mcp-iefdehyx)
- [Async Chain of Persistence Pattern: Designing for Failure in Event-Driven Systems](https://abp.io/community/articles/async-chain-of-persistence-pattern-wzjuy4gl)
- [Alper Ebiçoğlu](https://abp.io/community/members/alper) has published 2 new posts:
- [NDC London 2026: From a Developer's Perspective and My Personal Notes about AI](https://abp.io/community/articles/ndc-london-2026-a-.net-conf-from-a-developers-perspective-07wp50yl)
- [Which Open-Source PDF Libraries Are Recently Popular? A Data-Driven Look At PDF Topic](https://abp.io/community/articles/which-opensource-pdf-libraries-are-recently-popular-a-g68q78it)
- [Stop Spam and Toxic Users in Your App with AI](https://abp.io/community/articles/stop-spam-and-toxic-users-in-your-app-with-ai-3i0xxh0y) by [Engincan Veske](https://abp.io/community/members/EngincanV)
- [How AI Is Changing Developers](https://abp.io/community/articles/how-ai-is-changing-developers-e8y4a85f) by [Liming Ma](https://abp.io/community/members/maliming)
- [JetBrains State of Developer Ecosystem Report 2025 — Key Insights](https://abp.io/community/articles/jetbrains-state-of-developer-ecosystem-report-2025-key-z0638q5e) by [Tarık Özdemir](https://abp.io/community/members/mtozdemir)
- [Integrating AI into ABP.IO Applications: The Complete Guide to Volo.Abp.AI and AI Management Module](https://abp.io/community/articles/integrating-ai-into-abp.io-applications-the-complete-guide-jc9fbjq0) by [Adnan Ali](https://abp.io/community/members/adnanaldaim)
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.2/release-info/road-map) documentation to learn about the release schedule and planned features for the next releases. Please try ABP v10.2 RC and provide feedback to help us release a more stable version.
Thanks for being a part of this community!

BIN
docs/en/Blog-Posts/2026-02-25 v10_2_Preview/ai-management-openai-anythingllm.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
docs/en/Blog-Posts/2026-02-25 v10_2_Preview/chat-playground.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/en/Blog-Posts/2026-02-25 v10_2_Preview/cover-image.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

BIN
docs/en/Blog-Posts/2026-02-25 v10_2_Preview/file-management-rba.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
docs/en/Blog-Posts/2026-02-25 v10_2_Preview/mcp-servers.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-embedder.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-file-upload.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-vector-store.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
docs/en/Blog-Posts/2026-02-25 v10_2_Preview/studio-switch-to-preview.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
docs/en/Blog-Posts/2026-02-25 v10_2_Preview/test-connection.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

227
docs/en/Community-Articles/2026-03-09-Automate-Localhost-Access-for-Expo/POST.md

@ -0,0 +1,227 @@
# Automate Localhost Access for Expo: A Guide to Dynamic Cloudflare Tunnels & Dev Builds
Every mobile developer eventually hits the "Localhost Wall." You have built a brilliant API on your machine, and your React Native app works perfectly in the iOS Simulator or Android Emulator. But the moment you pick up a physical device to test real-world performance or camera features, everything breaks.
### The Problem: Why Your Phone Can’t See localhost
When you run a backend server on your computer, localhost refers to the "loopback" address and essentially, the computer talking to itself. Your physical iPhone or Android device is a separate node on the network. From its perspective, localhost is itself, not your development machine. Without a direct bridge, your mobile app is shouting into a void, unable to reach the API sitting just inches away on your desk.
### The Conflict: The Fragility of Local IP Addresses
The traditional workaround is to find the local IP address of your device and hardcode it into your app. However, this approach has many obstacles that make it difficult to use:
- **Network Volatility:** Your router might assign you a new IP address tomorrow, forcing you to update your code constantly.
- **The SSL Headache:** Modern mobile operating systems and many OAuth providers (like Google or Auth0) strictly require **HTTPS**. Running a local development server with valid SSL certificates is a notorious configuration nightmare.
- **Broken OAuth flows:** Most authentication providers refuse to redirect to a non-secure `http` address or a random local IP, effectively locking you out of testing login features on a real device.
### The Solution: Cloudflare Tunnel as a Secure Bridge
This is where **Cloudflare Tunnel** changes the game. Instead of poking holes in your firewall or wrestling with self-signed certificates, Cloudflare Tunnel creates a secure, outbound-only connection between your local machine and the Cloudflare edge.
It provides you with a **public, HTTPS-enabled URL** (e.g., `https://random-word.trycloudflare.com`) that automatically points to your local port. To your mobile device, your local backend looks like a standard, secure production API. It bypasses network restrictions, satisfies SSL requirements, and—when paired with a simple automation script—makes "localhost" development on physical devices completely seamless.
### 1. Architecture Overview
In order to understand why this setup is so effective, it is better to visualize the data flow. Traditionally, your mobile device would try to ping your laptop directly over Wi-Fi that is often blocked by firewalls or complicated by internal IP routing.
#### Workflow Summary: The Secure "Middleman"
The Cloudflare Tunnel acts as a persistent, encrypted bridge between your local environment and the public internet. Here is how the traffic flows in a standard development session:
1. **The Connector:** You run a small `cloudflared` daemon on your development machine. It establishes an **outbound** connection to Cloudflare’s nearest edge server. Because it is outbound, you don't need to open any ports on your home or office router.
2. **The Public Endpoint:** Cloudflare provides a temporary, unique HTTPS URL (e.g., `https://example-tunnel.trycloudflare.com`). This URL is globally accessible.
3. **The Mobile Request:** Your React Native app that is running on a physical iPhone or Android sends an API request to that HTTPS URL. To the phone, this looks like any other secure production website.
4. **The Local Handoff:** Cloudflare receives the request and "tunnels" it down the active connection to your machine. The `cloudflared` tool then forwards that request to your local backend whether it's running on `.NET` at port `44358`, `Node.js` at `3000`, or `Rails` at `3000`.
5. **The Response:** Your backend processes the request and sends the data back through the same tunnel to the phone.
By sitting in the middle, Cloudflare handles the **SSL termination** and the **Global Routing**, ensuring your backend is reachable regardless of whether your phone is on the same Wi-Fi as your laptop.
### 2. Prerequisites
Before we bridge the gap between your mobile device and your local machine, ensure your development environment is equipped with the following core components.
To follow this guide, you will need:
- **Node.js & Package Manager:** A stable version of Node.js (LTS recommended) and either **npm** or **yarn** to manage dependencies and run the automation scripts.
- **Expo CLI:** Ensure you have the latest version of `expo` installed globally or within your project. We will be using this to manage the development server and build the application.
- **Cloudflared CLI:** This is the critical "connector" tool from Cloudflare. You’ll need it installed on your local machine to establish the tunnel.
- *Quick Tip:* You don't need a paid Cloudflare account; the **Quick Tunnels** used in this guide are free and require no login.
- **A Running Backend API:** Your local server (e.g., .NET, Node.js, Django, or Rails) should be active and listening on a specific port (like `44358` or `3000`).
### 3. Step-by-Step Implementation
Now, let’s configure the automation that makes this workflow "set it and forget it."
#### Phase A: Backend Configuration (The OAuth Handshake)
Modern mobile authentication often relies on **OAuth 2.0** or **OpenID Connect**. For the login flow to succeed, your backend must "trust" the redirect URI sent by the mobile app. ABP applications are an example for such handshake.
Even though we are using a Cloudflare URL for the API calls, the `auth-session` of Expo typically generates a `localhost` redirect for development. You must update your backend configuration (e.g., `appsettings.json` in a .NET TemplateTwo setup) to allow this:
**File:** `src/YourProject.DbMigrator/appsettings.json`
```json
{
"OpenIddict": {
"Applications": {
"Mobile_App": {
"ClientId": "Mobile_App",
"RootUrl": "exp://localhost:19000"
}
}
}
}
```
**Note:** By setting the `RootUrl` to `exp://localhost:19000`, you ensure that once the user authenticates via the tunnel's secure page, the mobile OS knows exactly how to hand the token back to your running Expo instance.
#### Phase B: The "Magic" Script (Automating the Tunnel)
The primary headache with free Cloudflare Tunnels is that they generate a **random URL** every time you restart the service. Manually copying `https://shiny-new-url.trycloudflare.com` into your frontend code every morning is a productivity killer.
We solve this with a **Node.js automation script** that launches the tunnel, "listens" to the terminal output to find the new URL, and automatically injects it into your project's configuration.
**File:** `react-native/scripts/tunnel.js`
```js
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
// Target files for automation
const tunnelConfigFile = path.join(__dirname, '..', 'tunnel-config.json');
const environmentFile = path.join(__dirname, '..', 'Environment.ts');
// 1. Launch the Cloudflare Tunnel pointing to your local API port
const cloudflared = spawn('cloudflared', ['tunnel', '--url', 'http://localhost:44358']);
let domainCaptured = false;
cloudflared.stdout.on('data', data => {
const output = data.toString();
console.log(output); // Keep logs visible for debugging
if (!domainCaptured) {
// 2. Regex to catch the dynamic "trycloudflare" URL
const urlMatch = output.match(/https:\/\/([a-z0-9-]+\.trycloudflare\.com)/);
if (urlMatch) {
const domain = urlMatch[1];
// 3. Save to a JSON file for the app to read
fs.writeFileSync(tunnelConfigFile, JSON.stringify({ domain }, null, 2));
// 4. Update the fallback value in Environment.ts directly
let envContent = fs.readFileSync(environmentFile, 'utf8');
envContent = envContent.replace(
/let tunnelDomain = '[^']*'; \/\/ fallback/,
`let tunnelDomain = '${domain}'; // fallback`,
);
fs.writeFileSync(environmentFile, envContent, 'utf8');
console.log(`\n✅ Tunnel Synchronized: ${domain}`);
domainCaptured = true;
}
}
});
```
By capturing the trycloudflare.com domain programmatically, we treat the tunnel like a dynamic environment variable. This ensures that your mobile app, your backend OAuth settings, and your API client stay in perfect sync without a single keystroke from you.
#### Phase C: Environment Integration
To make this work within your React Native code, your `Environment.ts` file needs to be "smart" enough to look for the generated config file. We use a `try/catch` block so the app doesn't crash if the tunnel isn't running.
**File:** `react-native/Environment.ts`
```tsx
let tunnelDomain = 'your-default-fallback.com'; // fallback
try {
// Pull the latest domain from the script's output
const tunnelConfig = require('./tunnel-config.json');
if (tunnelConfig?.domain) {
tunnelDomain = tunnelConfig.domain;
}
} catch (e) {
console.warn('⚠️ No active tunnel config found. Using fallback.');
}
const apiUrl = `https://${tunnelDomain}`;
export const getEnvVars = () => {
return {
apiUrl,
// Other environment variables...
};
};
```
This setup creates a **"Single Source of Truth."** When you run the script, it updates `tunnel-config.json`, and your app instantly points to the correct secure endpoint.
### 4. Integration with Expo Development Builds
While you can technically use the standard **Expo Go** app for basic API testing, professional React Native workflows, especially those involving secure authentication and custom networking, rely on **Expo Development Builds**.
#### Why Development Builds are Essential for This Workflow
Standard Expo Go is a "one-size-fits-all" sandbox. However, as your app grows, it needs to behave more like a real, standalone binary. Development Builds are preferred for two main reasons:
- **Custom URL Schemes:** For OAuth flows (like the one configured in Phase A), your app needs to handle specific deep links (e.g., `myapp://`). Expo Go has its own internal URL handling that can sometimes conflict with complex redirect logic. A Development Build allows you to define your own scheme, ensuring the Cloudflare-tunneled backend knows exactly where to send the user back after login.
- **Native Dependency Control:** If your app uses native modules for secure storage, biometrics, or advanced networking, Expo Go won't support them. A Development Build includes your project's specific native code while still giving you the "hot reloading" developer experience of Expo.
#### Configuring the Build for Tunnelling
To ensure your development build is ready for the Cloudflare tunnel, you'll typically use the `expo-dev-client` package. This transforms your app into a powerful developer tool that can switch between different local or tunneled environments on the fly.
> **Pro Tip:** When you run `npx expo start`, your Development Build will look for the `apiUrl` we configured in `Environment.ts`. Since our script has already injected the Cloudflare URL, the physical device will connect to your local backend through the tunnel the moment the app loads.
### 5. Execution Workflow
To get your entire stack synchronized, follow this specific launch order. This ensures the tunnel is active and the configuration files are updated before the React Native app attempts to read them.
#### Step 1: Start the Backend
Fire up your API (e.g., `.NET`, `Node`, `Go`). Ensure it is listening on the port defined in your `tunnel.js` (e.g., `44358`).
#### Step 2: Launch the Tunnel
In a new terminal, run your automation script.
Wait for the message: `✅ Tunnel Synchronized`. This confirms `tunnel-config.json` has been updated with the new `trycloudflare.com` domain.
#### Step 3: Start Expo
Finally, start your Expo development server:
```bash
npx expo start
```
Open the app on your physical device by scanning the QR code. Your app is now communicating with your local machine over a secure, global HTTPS bridge.
### 6. Troubleshooting & Best Practices
Even with automation, networking can be finicky. If your app isn't reaching the API, check these common roadblocks:
#### Common Pitfalls
- **Port Mismatches:** Ensure the port in your `tunnel.js` script (e.g., `44358`) exactly matches the port your backend is listening on. If your backend uses HTTPS locally, ensure the tunnel command reflects that (e.g., `https://localhost:port`).
- **Firewall & Ghost Processes:** Sometimes a previous `cloudflared` process hangs in the background. If you can't start a new tunnel, kill existing processes or check if your local firewall is blocking `cloudflared` from making outbound connections.
- **Expired Sessions:** Free "Quick Tunnels" are temporary. If you leave your computer on overnight, the tunnel might disconnect. Simply restart the script to generate a fresh, synced URL.
#### Security Note
Cloudflare Tunnels create a **publicly accessible URL**. While the random strings in `trycloudflare.com` provide "security through obscurity," anyone with that link can hit your local API.
- **Development Data Only:** Never use this setup with production databases or sensitive PII (Personally Identifiable Information).
- **Disable When Idle:** Close the tunnel terminal when you aren't actively developing to shut the "bridge" to your machine.
### 7. Conclusion & Future-Proofing
By replacing hardcoded local IPs with a dynamic Cloudflare Tunnel, you’ve transformed a clunky, manual process into a **"Set it and forget it"** workflow. You no longer have to worry about shifting Wi-Fi addresses or SSL certificate errors on physical devices. Your development environment now mirrors the behavior of a production app, providing more accurate testing and faster debugging.
#### The Road to Production: EAS
This tunneling strategy is the perfect companion for **EAS (Expo Application Services)**. As you move toward testing internal distributions, you can use these same environment patterns to point your EAS-built binaries to various staging or development endpoints.
With a secure bridge and an automated config, you are no longer tethered to a simulator. Grab your phone, head to a coffee shop, and keep building—your backend is now globally (and securely) following you.

201
docs/en/Community-Articles/2026-03-09-Resource-Based-Authorization-in-ABP-Framework/POST.md

@ -0,0 +1,201 @@
# Resource-Based Authorization in ABP Framework
ABP has a built-in permission system that supports role-based access control (RBAC). You define permissions, assign them to roles, and assign roles to users — once a user logs in, they automatically have the corresponding access. This covers the vast majority of real-world scenarios and is simple, straightforward, and easy to maintain.
However, there is one class of requirements it cannot handle: **different access rights for different instances of the same resource type**.
Take a bookstore application as an example. You define a `Books.Edit` permission and assign it to an editor role, so every editor can modify every book. But reality is often more nuanced:
- A specific book should only be editable by its assigned editor
- Certain books are only visible to specific users
- Different users have different levels of access to the same book
Standard permissions cannot address this, because their granularity is the *permission type*, not a *specific record*. The traditional approach requires designing your own database tables, writing query logic, and building a management UI from scratch — all of which is costly.
ABP Framework now ships with **Resource-Based Authorization** to solve exactly this problem. The core idea is to bind permissions to specific resource instances rather than just resource types. For example, you can grant a user permission to edit the price of *1984* specifically, while they have no access to any other book.
More importantly, the entire permission management workflow is handled through a built-in UI dialog — **no custom code needed for the management side**.
## How It Works
Each resource instance (e.g. a book) can have its own permission management dialog. Users who hold the `ManagePermissions` permission can open it and grant or revoke access for users, roles, or OAuth clients — all from the UI.
A **Permissions** action appears in each book's action menu:
![book-list](./book-list.png)
Clicking it opens the resource permission management dialog for that specific book. You can see who currently has access and click **Add permission** to grant more:
![resource-permission-dialog](./resource-permission-dialog.png)
The **Add permission** dialog lets you select a user, role, or OAuth client, then choose which permissions to grant:
![add-permission-dialog](./add-permission-dialog.png)
After saving, the new entry appears in the list immediately.
Each entry in the list also supports **Edit** and **Delete** actions. Clicking **Edit** opens the update dialog where you can adjust the granted permissions:
![update-permission-dialog](./update-permission-dialog.png)
Clicking **Delete** shows a confirmation prompt — confirming removes all permissions for that user, role, or OAuth client on this book:
![delete-permission-confirm](./delete-permission-confirm.png)
## Setting It Up
To get this working, you need to define your resource permissions and wire up the dialog.
### Defining Resource Permissions
```csharp
public static class BookStorePermissions
{
public const string GroupName = "BookStore";
public static class Books
{
public const string Default = GroupName + ".Books";
public const string ManagePermissions = Default + ".ManagePermissions";
public static class Resources
{
public const string Name = "Acme.BookStore.Books.Book";
public const string View = Name + ".View";
public const string Edit = Name + ".Edit";
public const string Delete = Name + ".Delete";
}
}
}
```
```csharp
public override void Define(IPermissionDefinitionContext context)
{
var group = context.AddGroup(BookStorePermissions.GroupName);
var bookPermission = group.AddPermission(BookStorePermissions.Books.Default);
// Users with this permission can open the resource permission dialog
bookPermission.AddChild(BookStorePermissions.Books.ManagePermissions);
context.AddResourcePermission(
name: BookStorePermissions.Books.Resources.View,
resourceName: BookStorePermissions.Books.Resources.Name,
managementPermissionName: BookStorePermissions.Books.ManagePermissions
);
context.AddResourcePermission(
name: BookStorePermissions.Books.Resources.Edit,
resourceName: BookStorePermissions.Books.Resources.Name,
managementPermissionName: BookStorePermissions.Books.ManagePermissions
);
context.AddResourcePermission(
name: BookStorePermissions.Books.Resources.Delete,
resourceName: BookStorePermissions.Books.Resources.Name,
managementPermissionName: BookStorePermissions.Books.ManagePermissions
);
}
```
The `managementPermissionName` acts as a gate: only users who hold `ManagePermissions` will see the resource permission dialog for a book.
### Wiring Up the Dialog (MVC)
Add the required script to your page and open the dialog using `abp.ModalManager`:
```html
@section scripts
{
<abp-script src="/Pages/Books/Index.js"/>
<abp-script src="/Pages/AbpPermissionManagement/resource-permission-management-modal.js" />
}
```
```javascript
var _permissionsModal = new abp.ModalManager({
viewUrl: abp.appPath + 'AbpPermissionManagement/ResourcePermissionManagementModal',
modalClass: 'ResourcePermissionManagement'
});
function openPermissionsModal(bookId, bookName) {
_permissionsModal.open({
resourceName: 'Acme.BookStore.Books.Book',
resourceKey: bookId,
resourceDisplayName: bookName
});
}
```
> For Blazor and Angular applications, ABP provides the equivalent `ResourcePermissionManagementModal` component and `ResourcePermissionManagementComponent`. See the [Permission Management Module](https://abp.io/docs/latest/modules/permission-management) documentation for details.
## Checking Permissions in Code
The UI manages the permission assignments; the code enforces them at runtime. In your application service, use `AuthorizationService.CheckAsync` to verify that the current user holds a specific permission on a given resource instance.
All ABP entities implement `IKeyedObject`, which the framework uses to extract the resource key automatically — so you can pass the entity object directly without building the key manually:
```csharp
public virtual async Task<BookDto> GetAsync(Guid id)
{
var book = await _bookRepository.GetAsync(id);
// Throws AbpAuthorizationException if the current user has no View permission on this book
await AuthorizationService.CheckAsync(book, BookStorePermissions.Books.Resources.View);
return ObjectMapper.Map<Book, BookDto>(book);
}
public virtual async Task<BookDto> UpdateAsync(Guid id, UpdateBookDto input)
{
var book = await _bookRepository.GetAsync(id);
await AuthorizationService.CheckAsync(book, BookStorePermissions.Books.Resources.Edit);
book.Name = input.Name;
await _bookRepository.UpdateAsync(book);
return ObjectMapper.Map<Book, BookDto>(book);
}
```
If you want to check a permission without throwing an exception — for example, to conditionally show or hide a button — use `IsGrantedAsync` instead, which returns a `bool`:
```csharp
var canEdit = await AuthorizationService.IsGrantedAsync(book, BookStorePermissions.Books.Resources.Edit);
```
## Don't Forget to Clean Up
Every resource permission grant is stored as a record in the database. When a book is deleted, those records are not removed automatically — orphaned permission data accumulates over time.
Make sure to clean up resource permissions whenever a resource is deleted:
```csharp
public virtual async Task DeleteAsync(Guid id)
{
await _bookRepository.DeleteAsync(id);
// Clean up all resource permissions for this book
await _resourcePermissionManager.DeleteAsync(
resourceName: BookStorePermissions.Books.Resources.Name,
resourceKey: id.ToString()
);
}
```
## Summary
Resource-Based Authorization fills the gap between "everyone can do this" and "only specific users can do this on specific resources." In practice, most of the work comes down to two things:
- Define resource permissions and wire up the built-in UI dialog so administrators can assign access through the interface
- Call `AuthorizationService.CheckAsync` in your application services to enforce those permissions at runtime
Storing permission grants, rendering the dialog, searching for users, roles, and OAuth clients — ABP handles all of that for you.
## References
- [Resource-Based Authorization](https://abp.io/docs/latest/framework/fundamentals/authorization/resource-based-authorization)
- [Authorization](https://abp.io/docs/latest/framework/fundamentals/authorization)
- [Permission Management Module](https://abp.io/docs/latest/modules/permission-management)

BIN
docs/en/Community-Articles/2026-03-09-Resource-Based-Authorization-in-ABP-Framework/add-permission-dialog.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
docs/en/Community-Articles/2026-03-09-Resource-Based-Authorization-in-ABP-Framework/book-list.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
docs/en/Community-Articles/2026-03-09-Resource-Based-Authorization-in-ABP-Framework/cover.jpeg

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
docs/en/Community-Articles/2026-03-09-Resource-Based-Authorization-in-ABP-Framework/delete-permission-confirm.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
docs/en/Community-Articles/2026-03-09-Resource-Based-Authorization-in-ABP-Framework/resource-permission-dialog.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
docs/en/Community-Articles/2026-03-09-Resource-Based-Authorization-in-ABP-Framework/update-permission-dialog.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

188
docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md

@ -0,0 +1,188 @@
# Operation Rate Limiting in ABP Framework
Almost every user-facing system eventually runs into the same problem: **some operations cannot be allowed to run without limits**.
Sometimes it's a cost issue — sending an SMS costs money, and generating a report hammers the database. Sometimes it's security — a login endpoint with no attempt limit is an open invitation for brute-force attacks. And sometimes it's a matter of fairness — your paid plan says "up to 100 data exports per month," and you need to actually enforce that.
What all these cases have in common is that the thing being limited isn't an HTTP request — it's a *business operation*, performed by a specific *who*, doing a specific *what*, against a specific *resource*.
ASP.NET Core ships with a built-in [rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) that sits in the HTTP pipeline. It's excellent for broad API protection — throttling requests per IP to fend off bots or DDoS traffic. But it only sees HTTP requests. It can tell you how many requests came from an IP address; it cannot tell you:
- **"How many verification codes has this phone number received today?"** The moment the user switches networks, the counter resets — completely useless
- **"How many reports has this user exported today?"** Switching from mobile to desktop gives them a fresh counter
- **"How many times has someone tried to log in as `alice`?"** An attacker rotating through dozens of IPs will never hit the per-IP limit
There's another gap: some rate-limiting logic has no corresponding HTTP endpoint at all — it lives inside an application service method called by multiple endpoints, or triggered by a background job. HTTP middleware has no place to hook in.
Real-world requirements tend to look like this:
- The same phone number can receive at most 3 verification codes per hour, regardless of which device or IP the request comes from
- Each user can generate at most 2 monthly sales reports per day, because a single report query scans millions of records
- Login attempts are limited to 5 failures per username per 5 minutes, *and* 20 failures per IP per hour — two independent counters, both enforced simultaneously
- Free-tier users get 50 AI calls per month, paid users get 500 — this is a product-defined quota, not a security measure
- Your system integrates with an LLM provider (OpenAI, Azure OpenAI, etc.) where every call has a real dollar cost. Without per-user or per-tenant limits, a single user can exhaust your monthly budget overnight
The pattern is clear: the identity being throttled is a **business identity** — a user, a phone number, a resource ID — not an IP address. And the action being throttled is a **business operation**, not an HTTP request.
ABP Framework's **Operation Rate Limiting** module is built for exactly this. It lets you enforce limits directly in your application or domain layer, with full awareness of who is doing what.
Add the package to your project:
```bash
abp add-package Volo.Abp.OperationRateLimiting
```
> Operation Rate Limiting is available starting from **ABP Framework 10.3**. See the [pull request](https://github.com/abpframework/abp/pull/25024) for details.
## Defining a Policy
The model is straightforward: define a named policy in `ConfigureServices`, then call `CheckAsync` wherever you need to enforce it.
Name your policies after the business action they protect — `"SendSmsCode"`, `"GenerateReport"`, `"CallAI"`. A clear name makes the intent obvious at the call site, and avoids the mystery of something like `"policy1"`.
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.AddPolicy("SendSmsCode", policy =>
{
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1)
.PartitionByParameter();
});
});
```
- `WithFixedWindow` sets the time window and maximum count — here, at most 1 call per minute
- `PartitionByParameter` means each distinct value you pass at call time (such as a phone number) gets its own independent counter
Then inject `IOperationRateLimitingChecker` and call `CheckAsync` at the top of the method you want to protect:
```csharp
public class SmsAppService : ApplicationService
{
private readonly IOperationRateLimitingChecker _rateLimitChecker;
public SmsAppService(IOperationRateLimitingChecker rateLimitChecker)
{
_rateLimitChecker = rateLimitChecker;
}
public async Task SendCodeAsync(string phoneNumber)
{
await _rateLimitChecker.CheckAsync("SendSmsCode", phoneNumber);
// Limit not exceeded — proceed with sending the SMS
}
}
```
`CheckAsync` checks the current usage against the limit and throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is already exceeded. If the check passes, it then increments the counter and proceeds. ABP's exception pipeline catches this automatically and returns a standard error response. Put `CheckAsync` first — the rate limit check is the gate, and everything else only runs if it passes.
## Choosing a Partition Type
The partition type controls **how counters are isolated from each other** — it's the most important decision when setting up a policy, because it determines *what dimension you're counting across*.
Getting this wrong can make your rate limiting completely ineffective. Using `PartitionByClientIp` for SMS verification? An attacker just needs to switch networks. Using `PartitionByCurrentUser` for a login endpoint? There's no current user before login, so the counter has nowhere to land.
- **`PartitionByParameter`** — uses the value you explicitly pass as the partition key. This is the most flexible option. Pass a phone number, an email address, a resource ID, or any business identifier you have at hand. It's the right choice whenever you know exactly what the "who" is.
- **`PartitionByCurrentUser`** — uses the authenticated user's ID, with no value to pass. Perfect for "each user gets N per day" scenarios where user identity is all you need.
- **`PartitionByClientIp`** — uses the client's IP address. Don't rely on this alone — it's too easy to rotate. Use it as a secondary layer alongside another partition type, as in the login example below.
- **`PartitionByEmail`** and **`PartitionByPhoneNumber`** — designed for pre-authentication flows where the user isn't logged in yet. They prefer the `Parameter` value you explicitly pass, and fall back to the current user's email or phone number if none is provided.
- **`PartitionBy`** — a custom async delegate that can produce any partition key you need. When the built-in options don't fit, you're free to implement whatever logic makes sense: look up a resource's owner in the database, derive a key from the user's subscription tier, partition by tenant — anything that returns a string.
> The rule of thumb: partition by the identity of whoever's behavior you're trying to limit.
## Combining Rules in One Policy
A single rule covers most cases, but sometimes you need to enforce limits across multiple dimensions simultaneously. Login protection is the textbook example: throttling by username alone doesn't stop an attacker from targeting many accounts; throttling by IP alone doesn't stop an attacker with a botnet. You need both, at the same time.
```csharp
options.AddPolicy("Login", policy =>
{
// Rule 1: at most 5 attempts per username per 5-minute window
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 5)
.PartitionByParameter());
// Rule 2: at most 20 attempts per IP per hour, counted independently
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20)
.PartitionByClientIp());
});
```
The two counters are completely independent. If `alice` fails 5 times, her account is locked — but other accounts from the same IP are unaffected. If an IP accumulates 20 failures, it's blocked — but `alice` can still be targeted from other IPs until their own counters fill up.
When multiple rules are present, the module uses a two-phase approach: it checks all rules first, and only increments counters if every rule passes. This prevents a rule from consuming quota on a request that would have been rejected by another rule anyway.
## Beyond Just Checking
Not every scenario calls for throwing an exception. `IOperationRateLimitingChecker` provides three additional methods for more nuanced control.
**`IsAllowedAsync`** performs a read-only check — it returns `true` or `false` without touching any counter. The most common use case is UI pre-checking: when a user opens the "send verification code" page, check the limit first. If they've already hit it, disable the button and show a countdown immediately, rather than making them click and get an error. That's a meaningfully better experience.
```csharp
var isAllowed = await _rateLimitChecker.IsAllowedAsync("SendSmsCode", phoneNumber);
```
**`GetStatusAsync`** also reads without incrementing, but returns richer data: `RemainingCount`, `RetryAfter`, and `CurrentCount`. This is what you need to build quota displays — "You have 2 exports remaining today" or "Please try again in 47 seconds" — which are far friendlier than a raw 429.
```csharp
var status = await _rateLimitChecker.GetStatusAsync("SendSmsCode", phoneNumber);
// status.RemainingCount, status.RetryAfter, status.IsAllowed ...
```
**`ResetAsync`** clears the counter for a given policy and context. Useful in admin panels where support staff can manually unblock a user, or in test environments where you need to reset state between runs.
```csharp
await _rateLimitChecker.ResetAsync("SendSmsCode", phoneNumber);
```
## When the Limit Is Hit
When `CheckAsync` triggers, it throws `AbpOperationRateLimitingException`, which:
- Inherits from `BusinessException` and maps to HTTP **429 Too Many Requests**
- Is handled automatically by ABP's exception pipeline
- Carries useful metadata: `RetryAfterSeconds`, `RemainingCount`, `MaxCount`, `CurrentCount`
By default, the error code sent to the client is a generic one from the module. If you want each operation to produce its own localized message — "Too many verification code requests, please wait before trying again" instead of a generic error — assign a custom error code to the policy:
```csharp
options.AddPolicy("SendSmsCode", policy =>
{
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1)
.PartitionByParameter()
.WithErrorCode("App:SmsCodeLimit");
});
```
> For details on mapping error codes to localized messages, see [Exception Handling](https://abp.io/docs/latest/framework/fundamentals/exception-handling) in the ABP docs.
## Turning It Off in Development
Rate limiting and local development don't mix well. When you're iterating quickly and calling the same endpoint a dozen times to test something, getting blocked by a 429 every few seconds is genuinely painful. Disable the module in your development environment:
```csharp
public override void ConfigureServices(ServiceConfigurationContext context)
{
var hostEnvironment = context.Services.GetHostingEnvironment();
Configure<AbpOperationRateLimitingOptions>(options =>
{
if (hostEnvironment.IsDevelopment())
{
options.IsEnabled = false;
}
});
}
```
## Summary
ABP's Operation Rate Limiting fills the gap that ASP.NET Core's HTTP middleware can't: rate limiting with real awareness of *who* is doing *what*. Define a named policy, pick a time window, a max count, and a partition type. Call `CheckAsync` wherever you need it. Counter storage, distributed locking, and exception handling are all taken care of.
## References
- [Operation Rate Limiting](https://abp.io/docs/latest/framework/infrastructure/operation-rate-limiting)
- [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit)
- [Exception Handling](https://abp.io/docs/latest/framework/fundamentals/exception-handling)

BIN
docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/cover.jpeg

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

4
docs/en/docs-nav.json

@ -807,6 +807,10 @@
"text": "Object to Object Mapping", "text": "Object to Object Mapping",
"path": "framework/infrastructure/object-to-object-mapping.md" "path": "framework/infrastructure/object-to-object-mapping.md"
}, },
{
"text": "Operation Rate Limiting",
"path": "framework/infrastructure/operation-rate-limiting.md"
},
{ {
"text": "Settings", "text": "Settings",
"path": "framework/infrastructure/settings.md" "path": "framework/infrastructure/settings.md"

492
docs/en/framework/infrastructure/operation-rate-limiting.md

@ -0,0 +1,492 @@
````json
//[doc-seo]
{
"Description": "Learn how to use the Operation Rate Limiting module in ABP Framework to control the frequency of specific operations like SMS sending, login attempts, and resource-intensive tasks."
}
````
# Operation Rate Limiting
ABP provides an operation rate limiting system that allows you to control the frequency of specific operations in your application. You may need operation rate limiting for several reasons:
* Do not allow sending an SMS verification code to the same phone number more than 3 times in an hour.
* Do not allow generating a "monthly sales report" more than 2 times per day for each user (if generating the report is resource-intensive).
* Restrict login attempts per IP address to prevent brute-force attacks.
> This is not for [ASP.NET Core's built-in rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) which works at the HTTP request pipeline level. This module works at the **application/domain code level** and is called explicitly from your services. See the [Combining with ASP.NET Core Rate Limiting](#combining-with-aspnet-core-rate-limiting) section for a comparison.
## Installation
You can open a command-line terminal and type the following command to install the [Volo.Abp.OperationRateLimiting](https://www.nuget.org/packages/Volo.Abp.OperationRateLimiting) package into your project:
````bash
abp add-package Volo.Abp.OperationRateLimiting
````
> If you haven't done it yet, you first need to install the [ABP CLI](../../../cli).
## Quick Start
This section shows the basic usage of the operation rate limiting system with a simple example.
### Defining a Policy
First, define a rate limiting policy in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md):
````csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.AddPolicy("SendSmsCode", policy =>
{
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1)
.PartitionByParameter();
});
});
````
* `"SendSmsCode"` is a unique name for this policy.
* `WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1)` means at most **1 request per minute**.
* `PartitionByParameter()` means the counter is keyed by the parameter you pass at check time (e.g., a phone number), so different phone numbers have independent counters.
### Checking the Limit
Then inject `IOperationRateLimitingChecker` and call `CheckAsync` in your service:
````csharp
public class SmsAppService : ApplicationService
{
private readonly IOperationRateLimitingChecker _rateLimitChecker;
public SmsAppService(IOperationRateLimitingChecker rateLimitChecker)
{
_rateLimitChecker = rateLimitChecker;
}
public async Task SendCodeAsync(string phoneNumber)
{
await _rateLimitChecker.CheckAsync("SendSmsCode", phoneNumber);
// If we reach here, the limit was not exceeded.
// Send the SMS code...
}
}
````
* `CheckAsync` increments the counter and throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is exceeded.
* Each phone number has its own counter because we used `PartitionByParameter()`.
* Passing `phoneNumber` directly is a shortcut for `new OperationRateLimitingContext { Parameter = phoneNumber }`. Extension methods are provided for all four methods (`CheckAsync`, `IsAllowedAsync`, `GetStatusAsync`, `ResetAsync`) when you only need to pass a `parameter` string.
That's the basic usage. The following sections explain each concept in detail.
## Defining Policies
Policies are defined using `AbpOperationRateLimitingOptions` in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md). Each policy has a unique name, one or more rules, and a partition strategy.
### Single-Rule Policies
For simple scenarios, use the `WithFixedWindow` shortcut directly on the policy builder:
````csharp
options.AddPolicy("SendSmsCode", policy =>
{
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1)
.PartitionByParameter();
});
````
### Multi-Rule Policies
Use `AddRule` to combine multiple rules. All rules are checked together (**AND** logic) — a request is allowed only when **all** rules pass:
````csharp
options.AddPolicy("Login", policy =>
{
// Rule 1: Max 5 attempts per 5 minutes per username
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 5)
.PartitionByParameter());
// Rule 2: Max 20 attempts per hour per IP
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20)
.PartitionByClientIp());
});
````
> When multiple rules are present, the module uses a **two-phase check**: it first verifies all rules without incrementing counters, then increments only if all rules pass. This prevents wasted quota when one rule would block the request.
### Custom Error Code
By default, the exception uses the error code `Volo.Abp.OperationRateLimiting:010001`. You can override it per policy:
````csharp
options.AddPolicy("SendSmsCode", policy =>
{
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1)
.PartitionByParameter()
.WithErrorCode("App:SmsCodeLimit");
});
````
## Partition Types
Each rule must specify a **partition type** that determines how requests are grouped. Requests with different partition keys have independent counters.
### PartitionByParameter
Uses the `Parameter` value from the context you pass to `CheckAsync`:
````csharp
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1)
.PartitionByParameter();
// Each phone number has its own counter
await checker.CheckAsync("SendSmsCode",
new OperationRateLimitingContext { Parameter = phoneNumber });
````
### PartitionByCurrentUser
Uses `ICurrentUser.Id` as the partition key. The user must be authenticated:
````csharp
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 10)
.PartitionByCurrentUser();
````
> If you need to check rate limits for a specific user (e.g., admin checking another user's limit), use `PartitionByParameter()` and pass the user ID as the `Parameter`.
### PartitionByCurrentTenant
Uses `ICurrentTenant.Id` as the partition key. Uses `"host"` for the host side when no tenant is active:
````csharp
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100)
.PartitionByCurrentTenant();
````
### PartitionByClientIp
Uses `IWebClientInfoProvider.ClientIpAddress` as the partition key:
````csharp
policy.WithFixedWindow(TimeSpan.FromMinutes(15), maxCount: 10)
.PartitionByClientIp();
````
> This requires an ASP.NET Core environment. In non-web scenarios, the IP address cannot be determined and an exception will be thrown. Use `PartitionByParameter()` if you need to pass the IP explicitly.
### PartitionByEmail
Resolves from `context.Parameter` first, then falls back to `ICurrentUser.Email`:
````csharp
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1)
.PartitionByEmail();
// For unauthenticated users, pass the email explicitly:
await checker.CheckAsync("SendEmailCode",
new OperationRateLimitingContext { Parameter = email });
````
### PartitionByPhoneNumber
Works the same way as `PartitionByEmail`: resolves from `context.Parameter` first, then falls back to `ICurrentUser.PhoneNumber`.
### Custom Partition (PartitionBy)
You can provide a custom async function to generate the partition key. The async signature allows you to perform database queries or other I/O operations:
````csharp
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100)
.PartitionBy(ctx => Task.FromResult(
$"{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}"));
````
## Multi-Tenancy
By default, partition keys do not include tenant information — for partition types like `PartitionByParameter`, `PartitionByCurrentUser`, `PartitionByClientIp`, etc., counters are shared across tenants unless you call `WithMultiTenancy()`. Note that `PartitionByCurrentTenant()` is inherently per-tenant since the partition key is the tenant ID itself, and `PartitionByClientIp()` is typically kept global since the same IP should share a counter regardless of tenant.
You can enable tenant isolation for a rule by calling `WithMultiTenancy()`:
````csharp
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5)
.WithMultiTenancy()
.PartitionByParameter());
````
When multi-tenancy is enabled, the cache key includes the tenant ID, so each tenant has independent counters:
* **Global key format:** `orl:{PolicyName}:{RuleKey}:{PartitionKey}`
* **Tenant-isolated key format:** `orl:t:{TenantId}:{PolicyName}:{RuleKey}:{PartitionKey}`
## Checking the Limit
Inject `IOperationRateLimitingChecker` to interact with rate limits. It provides four methods:
### CheckAsync
The primary method. It checks the rate limit and **increments the counter** if allowed. Throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is exceeded:
````csharp
await checker.CheckAsync("SendSmsCode",
new OperationRateLimitingContext { Parameter = phoneNumber });
````
### IsAllowedAsync
A read-only check that returns `true` or `false` **without incrementing** the counter. Useful for UI pre-checks (e.g., disabling a button before the user clicks):
````csharp
var isAllowed = await checker.IsAllowedAsync("SendSmsCode",
new OperationRateLimitingContext { Parameter = phoneNumber });
````
### GetStatusAsync
Returns detailed status information **without incrementing** the counter:
````csharp
var status = await checker.GetStatusAsync("SendSmsCode",
new OperationRateLimitingContext { Parameter = phoneNumber });
// status.IsAllowed - whether the next request would be allowed
// status.RemainingCount - how many requests are left in this window
// status.RetryAfter - time until the window resets
// status.MaxCount - maximum allowed count
// status.CurrentCount - current usage count
````
### ResetAsync
Resets the counter for a specific policy and context. This can be useful for administrative operations:
````csharp
await checker.ResetAsync("SendSmsCode",
new OperationRateLimitingContext { Parameter = phoneNumber });
````
## The Exception
When a rate limit is exceeded, `CheckAsync` throws `AbpOperationRateLimitingException`. This exception:
* Extends `BusinessException` and implements `IHasHttpStatusCode` with status code **429** (Too Many Requests).
* Is automatically handled by ABP's exception handling pipeline and serialized into the HTTP response.
The exception uses one of two error codes depending on the policy type:
| Error Code | Constant | When Used |
|---|---|---|
| `Volo.Abp.OperationRateLimiting:010001` | `AbpOperationRateLimitingErrorCodes.ExceedLimit` | Regular rate limit exceeded (has a retry-after window) |
| `Volo.Abp.OperationRateLimiting:010002` | `AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently` | Ban policy (`maxCount: 0`, permanently denied) |
You can override the error code per policy using `WithErrorCode()`. When a custom code is set, it is always used regardless of the policy type.
The exception includes the following data properties:
| Key | Type | Description |
|-----|------|-------------|
| `PolicyName` | string | Name of the triggered policy |
| `MaxCount` | int | Maximum allowed count |
| `CurrentCount` | int | Current usage count |
| `RemainingCount` | int | Remaining allowed count |
| `RetryAfterSeconds` | int | Seconds until the window resets (`0` for ban policies) |
| `RetryAfterMinutes` | int | Minutes until the window resets, rounded down (`0` for ban policies) |
| `RetryAfter` | string | Localized retry-after description (e.g., "5 minutes"); absent for ban policies |
| `WindowDurationSeconds` | int | Total window duration in seconds |
| `WindowDescription` | string | Localized window description |
| `RuleDetails` | List | Per-rule details (for multi-rule policies) |
## Configuration
### AbpOperationRateLimitingOptions
`AbpOperationRateLimitingOptions` is the main options class for the operation rate limiting system:
````csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.IsEnabled = true;
options.LockTimeout = TimeSpan.FromSeconds(5);
});
````
* **`IsEnabled`** (`bool`, default: `true`): Global switch to enable or disable rate limiting. When set to `false`, all `CheckAsync` calls pass through without checking. This is useful for disabling rate limiting in development (see [below](#disabling-in-development)).
* **`LockTimeout`** (`TimeSpan`, default: `5 seconds`): Timeout for acquiring the distributed lock during counter increment operations.
## Advanced Usage
### Disabling in Development
You may want to disable rate limiting during development to avoid being blocked while testing:
````csharp
public override void ConfigureServices(ServiceConfigurationContext context)
{
var hostEnvironment = context.Services.GetHostingEnvironment();
Configure<AbpOperationRateLimitingOptions>(options =>
{
if (hostEnvironment.IsDevelopment())
{
options.IsEnabled = false;
}
});
}
````
### Ban Policy (maxCount: 0)
Setting `maxCount` to `0` creates a ban policy that permanently denies all requests regardless of the window duration. The `RetryAfter` value will be `null` since there is no window to wait for. The exception uses the error code `Volo.Abp.OperationRateLimiting:010002` (`AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently`) with the message "Operation rate limit exceeded. This request is permanently denied.":
````csharp
options.AddPolicy("BlockedUser", policy =>
{
policy.WithFixedWindow(TimeSpan.FromHours(24), maxCount: 0)
.PartitionByParameter();
});
````
### Passing Extra Properties
Use `ExtraProperties` on `OperationRateLimitingContext` to pass additional context data. These values are available in custom partition resolvers and are included in the exception data when the limit is exceeded:
````csharp
await checker.CheckAsync("ApiCall", new OperationRateLimitingContext
{
Parameter = apiEndpoint,
ExtraProperties =
{
["DeviceId"] = deviceId,
["ClientVersion"] = clientVersion
}
});
````
### Pre-checking Before Expensive Operations
Use `IsAllowedAsync` or `GetStatusAsync` to check the limit **before** performing expensive work (e.g., validating input or querying the database):
````csharp
public async Task<SendCodeResultDto> SendCodeAsync(string phoneNumber)
{
var context = new OperationRateLimitingContext { Parameter = phoneNumber };
// Check limit before doing any work
var status = await _rateLimitChecker.GetStatusAsync("SendSmsCode", context);
if (!status.IsAllowed)
{
return new SendCodeResultDto
{
Success = false,
RetryAfterSeconds = (int)(status.RetryAfter?.TotalSeconds ?? 0)
};
}
// Now do the actual work and increment the counter
await _rateLimitChecker.CheckAsync("SendSmsCode", context);
await _smsSender.SendAsync(phoneNumber, GenerateCode());
return new SendCodeResultDto { Success = true };
}
````
> `IsAllowedAsync` and `GetStatusAsync` are read-only — they do not increment the counter. Only `CheckAsync` increments.
### Checking on Behalf of Another User
`PartitionByCurrentUser()`, `PartitionByCurrentTenant()`, and `PartitionByClientIp()` always resolve from their respective services (`ICurrentUser`, `ICurrentTenant`, `IWebClientInfoProvider`) and do not accept explicit overrides. This design avoids partition key conflicts in [composite policies](#multi-rule-policies) where `Parameter` is shared across all rules.
If you need to check or enforce rate limits for a **specific user, tenant, or IP**, define the policy with `PartitionByParameter()` and pass the value explicitly:
````csharp
// Policy definition: use PartitionByParameter for explicit control
options.AddPolicy("UserApiLimit", policy =>
{
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100)
.PartitionByParameter();
});
````
````csharp
// Check current user's limit
await checker.CheckAsync("UserApiLimit",
new OperationRateLimitingContext { Parameter = CurrentUser.Id.ToString() });
// Admin checking another user's limit
await checker.CheckAsync("UserApiLimit",
new OperationRateLimitingContext { Parameter = targetUserId.ToString() });
// Check a specific IP in a background job
await checker.CheckAsync("UserApiLimit",
new OperationRateLimitingContext { Parameter = ipAddress });
````
This approach gives you full flexibility while keeping the API simple — `PartitionByCurrentUser()` is a convenience shortcut for "always use the current authenticated user", and `PartitionByParameter()` is for "I want to specify the value explicitly".
### Combining with ASP.NET Core Rate Limiting
This module and ASP.NET Core's built-in [rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) serve different purposes and can be used together:
| | ASP.NET Core Rate Limiting | Operation Rate Limiting |
|---|---|---|
| **Level** | HTTP request pipeline | Application/domain code |
| **Scope** | All incoming requests | Specific business operations |
| **Usage** | Middleware (automatic) | Explicit `CheckAsync` calls |
| **Typical use** | API throttling, DDoS protection | Business logic limits (SMS, reports) |
A common pattern is to use ASP.NET Core middleware for broad API protection and this module for fine-grained business operation limits.
## Extensibility
### Custom Store
The default store uses ABP's `IDistributedCache`. You can replace it by implementing `IOperationRateLimitingStore`:
````csharp
public class MyCustomStore : IOperationRateLimitingStore, ITransientDependency
{
public Task<OperationRateLimitingStoreResult> IncrementAsync(
string key, TimeSpan duration, int maxCount)
{
// Your custom implementation (e.g., Redis Lua script for atomicity)
}
public Task<OperationRateLimitingStoreResult> GetAsync(
string key, TimeSpan duration, int maxCount)
{
// Read-only check
}
public Task ResetAsync(string key)
{
// Reset the counter
}
}
````
ABP's [dependency injection](../../fundamentals/dependency-injection.md) system will automatically use your implementation since it replaces the default one.
### Custom Rule
You can implement custom rate limiting algorithms (e.g., sliding window, token bucket) by implementing `IOperationRateLimitingRule` and registering it with `AddRule<TRule>()`:
````csharp
policy.AddRule<MySlidingWindowRule>();
````
### Custom Formatter
Replace `IOperationRateLimitingFormatter` to customize how time durations are displayed in error messages (e.g., "5 minutes", "2 hours 30 minutes").
### Custom Policy Provider
Replace `IOperationRateLimitingPolicyProvider` to load policies from a database or external configuration source instead of the in-memory options.
## See Also
* [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit)
* [Distributed Caching](../fundamentals/caching.md)
* [Exception Handling](../fundamentals/exception-handling.md)

142
docs/en/framework/ui/angular/pwa-configuration.md

@ -284,11 +284,53 @@ In addition to updated icons, the library will generate splash screens. However,
## 3. Configure Service Worker ## 3. Configure Service Worker
### 3.1 Modify Asset Groups Once the PWA schematic is installed and the manifest is customized, you should review and tune the Angular service worker configuration.
Angular has defined some static files to be cached by the service worker, but they are not 100% accurate. Let's change it. The configuration lives in `ngsw-config.json` and controls **what is cached**, **how it is cached**, and **for how long**. See Angular’s [service worker configuration](https://angular.dev/ecosystem/service-workers/config) for full details.
Open _ngsw-config.json_ file and replace its content with this:
### 3.1. Minimal starter configuration
This is a **simple, safe default** that works well for most ABP Angular applications:
```json
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": ["/favicon.ico", "/index.html", "/manifest.webmanifest", "/*.css", "/*.js"]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(eot|svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}
```
- `app` group: prefetches the application shell (HTML, JS, CSS, manifest) so the UI loads quickly and works offline after first visit.
- `assets` group: lazily caches static assets (images, fonts, etc.) as they are requested.
> **Note**: The `"/*.js"` pattern is intentionally generic to work with modern Angular build outputs. Always adapt patterns to match your actual `dist/<project>/browser` files if you customize the build.
### 3.2. Advanced: separate lazy modules
If your app uses many lazy‑loaded feature modules and you want more control over their caching, you can split them into a dedicated group:
```json ```json
{ {
@ -304,11 +346,10 @@ Open _ngsw-config.json_ file and replace its content with this:
"/index.html", "/index.html",
"/manifest.webmanifest", "/manifest.webmanifest",
"/*.css", "/*.css",
"/common-es2015.*.js", "/main.*.js",
"/main-es2015.*.js", "/polyfills.*.js",
"/polyfills-es2015.*.js", "/runtime.*.js",
"/runtime-es2015.*.js", "/vendor.*.js"
"/vendor-es2015.*.js"
] ]
} }
}, },
@ -317,14 +358,7 @@ Open _ngsw-config.json_ file and replace its content with this:
"installMode": "lazy", "installMode": "lazy",
"updateMode": "prefetch", "updateMode": "prefetch",
"resources": { "resources": {
"files": [ "files": ["/*.*.js", "!/main.*.js", "!/polyfills.*.js", "!/runtime.*.js", "!/vendor.*.js"]
"/*-es2015.*.js",
"!/common-es2015.*.js",
"!/main-es2015.*.js",
"!/polyfills-es2015.*.js",
"!/runtime-es2015.*.js",
"!/vendor-es2015.*.js"
]
} }
}, },
{ {
@ -334,7 +368,7 @@ Open _ngsw-config.json_ file and replace its content with this:
"resources": { "resources": {
"files": [ "files": [
"/assets/**", "/assets/**",
"/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" "/*.(eot|svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
] ]
} }
} }
@ -342,8 +376,76 @@ Open _ngsw-config.json_ file and replace its content with this:
} }
``` ```
In case you want to cache other static files, please refer to the [service worker configuration document](https://angular.dev/ecosystem/service-workers/config) on Angular.dev. - `app`: core shell bundles that should always be prefetched.
- `modules`: lazy‑loaded feature bundles that are cached only when actually used, then updated in the background.
- `assets`: all static files.
For ABP Angular apps that use `index.csr.html` (CSR/SSR setups), you can add it into the `app` group as well:
```json
"/index.csr.html",
"/index.html",
```
### 3.3. Example `dataGroups` for API caching
`dataGroups` control **HTTP request caching**. This is highly application‑specific, but a small, explicit example is very helpful:
```json
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
// ...
],
"dataGroups": [
{
"name": "api",
"urls": ["/api/**"],
"cacheConfig": {
"strategy": "freshness",
"maxSize": 50,
"maxAge": "1h",
"timeout": "5s"
}
}
]
}
```
- `urls`: which HTTP URLs are cached (`/api/**` is an example; narrow this to specific APIs in real apps).
- `strategy: "freshness"`: try network first, fall back to cache if the network is too slow (`timeout`) or offline.
- `maxSize`: maximum number of request entries stored.
- `maxAge`: how long a cached response is considered fresh.
For endpoints where stale data is acceptable and you want faster responses, you can use `"strategy": "performance"` instead.
> **Important**: Be careful not to cache authenticated or highly dynamic endpoints unless you fully understand the implications (stale user data, security, GDPR, etc.).
### 3.4. Build and verify
After changing `ngsw-config.json`:
1. **Build with service worker enabled** (production config):
```bash
ng build --configuration production
```
2. **Serve the built app over HTTP/HTTPS** and open it in the browser.
3. In Chrome DevTools → **Application**:
- **Service Workers**: ensure `ngsw-worker.js` is _installed_ and _controlling the page_.
- **Manifest**: verify the manifest and that the app is installable.
### 3.2 Set Data Groups 4. **Test offline**:
- Load the app once while online.
- Enable “Offline” in DevTools → Network and reload.
- The shell and static assets configured in `assetGroups` should still work.
This part is unique to your project. We recommend being very careful about which endpoints to cache. Please refer to [service worker configuration document](https://angular.dev/ecosystem/service-workers/config) on Angular.dev for details. For further customization, refer to the official Angular service worker docs:
[https://angular.dev/ecosystem/service-workers/config](https://angular.dev/ecosystem/service-workers/config).

4
docs/en/low-code/custom-endpoints.md

@ -106,7 +106,7 @@ The full [Scripting API](scripting-api.md) (`db` object) is available for queryi
"route": "/api/custom/products/stats", "route": "/api/custom/products/stats",
"method": "GET", "method": "GET",
"requireAuthentication": false, "requireAuthentication": false,
"javascript": "var totalCount = await db.count('LowCodeDemo.Products.Product');\nvar productTable = await db.query('LowCodeDemo.Products.Product');\nvar avgPrice = totalCount > 0 ? await productTable.average(p => p.Price) : 0;\nreturn ok({ totalProducts: totalCount, averagePrice: avgPrice });" "javascript": "var totalCount = await db.count('LowCodeDemo.Products.Product');\nvar avgPrice = totalCount > 0 ? await db.query('LowCodeDemo.Products.Product').average(p => p.Price) : 0;\nreturn ok({ totalProducts: totalCount, averagePrice: avgPrice });"
} }
``` ```
@ -118,7 +118,7 @@ The full [Scripting API](scripting-api.md) (`db` object) is available for queryi
"route": "/api/custom/customers/search", "route": "/api/custom/customers/search",
"method": "GET", "method": "GET",
"requireAuthentication": true, "requireAuthentication": true,
"javascript": "var searchTerm = query.q || '';\nvar customerTable = await db.query('LowCodeDemo.Customers.Customer');\nvar customers = await customerTable\n .where(c => c.Name.toLowerCase().includes(searchTerm.toLowerCase()))\n .take(10)\n .toList();\nreturn ok(customers.map(c => ({ id: c.Id, name: c.Name, email: c.EmailAddress })));" "javascript": "var searchTerm = query.q || '';\nvar customers = await db.query('LowCodeDemo.Customers.Customer')\n .where(c => c.Name.toLowerCase().includes(searchTerm.toLowerCase()))\n .take(10)\n .toList();\nreturn ok(customers.map(c => ({ id: c.Id, name: c.Name, email: c.EmailAddress })));"
} }
``` ```

94
docs/en/low-code/scripting-api.md

@ -21,8 +21,7 @@ The `db` object is the main entry point for all data operations.
```javascript ```javascript
// Immutable pattern — each call creates a new builder // Immutable pattern — each call creates a new builder
var entityTable = await db.query('Entity'); var baseQuery = db.query('Entity').where(x => x.Active);
var baseQuery = await entityTable.where(x => x.Active);
var cheap = baseQuery.where(x => x.Price < 100); // baseQuery unchanged var cheap = baseQuery.where(x => x.Price < 100); // baseQuery unchanged
var expensive = baseQuery.where(x => x.Price > 500); // baseQuery unchanged var expensive = baseQuery.where(x => x.Price > 500); // baseQuery unchanged
``` ```
@ -32,15 +31,13 @@ var expensive = baseQuery.where(x => x.Price > 500); // baseQuery unchanged
### Basic Queries ### Basic Queries
```javascript ```javascript
var productTable = await db.query('LowCodeDemo.Products.Product'); var products = await db.query('LowCodeDemo.Products.Product')
var products = await productTable
.where(x => x.Price > 100) .where(x => x.Price > 100)
.orderBy(x => x.Price) .orderBy(x => x.Price)
.take(10) .take(10)
.toList(); .toList();
var resultTable = await db.query('LowCodeDemo.Products.Product'); var result = await db.query('LowCodeDemo.Products.Product')
var result = await resultTable
.where(x => x.Price > 100 && x.Price < 500) .where(x => x.Price > 100 && x.Price < 500)
.where(x => x.StockCount > 0) .where(x => x.StockCount > 0)
.orderByDescending(x => x.Price) .orderByDescending(x => x.Price)
@ -66,12 +63,9 @@ var result = await resultTable
| `all(x => condition)` | Check if all records match | `Promise<boolean>` | | `all(x => condition)` | Check if all records match | `Promise<boolean>` |
| `isEmpty()` | Check if no results | `Promise<boolean>` | | `isEmpty()` | Check if no results | `Promise<boolean>` |
| `isSingle()` | Check if exactly one result | `Promise<boolean>` | | `isSingle()` | Check if exactly one result | `Promise<boolean>` |
| `first()` | Return first match, throws if empty | `Promise<object>` | | `first()` / `firstOrDefault()` | Return first match or null | `Promise<object\|null>` |
| `firstOrDefault()` | Return first match or null | `Promise<object\|null>` | | `last()` / `lastOrDefault()` | Return last match or null | `Promise<object\|null>` |
| `last()` | Return last match, throws if empty | `Promise<object>` | | `single()` / `singleOrDefault()` | Return single match or null | `Promise<object\|null>` |
| `lastOrDefault()` | Return last match or null | `Promise<object\|null>` |
| `single()` | Return single match, throws if empty/multiple | `Promise<object>` |
| `singleOrDefault()` | Return single match or null (throws if multiple) | `Promise<object\|null>` |
| `elementAt(index)` | Return element at index | `Promise<object\|null>` | | `elementAt(index)` | Return element at index | `Promise<object\|null>` |
| `select(x => projection)` | Project to custom shape | `QueryBuilder` | | `select(x => projection)` | Project to custom shape | `QueryBuilder` |
| `join(entity, alias, condition)` | Inner join | `QueryBuilder` | | `join(entity, alias, condition)` | Inner join | `QueryBuilder` |
@ -98,18 +92,16 @@ var minPrice = 100;
var config = { minStock: 10 }; var config = { minStock: 10 };
var nested = { range: { min: 50, max: 200 } }; var nested = { range: { min: 50, max: 200 } };
var entityTable = await db.query('Entity'); var result = await db.query('Entity').where(x => x.Price > minPrice).toList();
var result = await entityTable.where(x => x.Price > minPrice).toList(); var result2 = await db.query('Entity').where(x => x.StockCount > config.minStock).toList();
var result2 = await entityTable.where(x => x.StockCount > config.minStock).toList(); var result3 = await db.query('Entity').where(x => x.Price >= nested.range.min).toList();
var result3 = await entityTable.where(x => x.Price >= nested.range.min).toList();
``` ```
### Contains / IN Operator ### Contains / IN Operator
```javascript ```javascript
var targetPrices = [50, 100, 200]; var targetPrices = [50, 100, 200];
var entityTable = await db.query('Entity'); var products = await db.query('Entity')
var products = await entityTable
.where(x => targetPrices.includes(x.Price)) .where(x => targetPrices.includes(x.Price))
.toList(); .toList();
``` ```
@ -117,8 +109,7 @@ var products = await entityTable
### Select Projection ### Select Projection
```javascript ```javascript
var productTable = await db.query('LowCodeDemo.Products.Product'); var projected = await db.query('LowCodeDemo.Products.Product')
var projected = await productTable
.where(x => x.Price > 0) .where(x => x.Price > 0)
.select(x => ({ ProductName: x.Name, ProductPrice: x.Price })) .select(x => ({ ProductName: x.Name, ProductPrice: x.Price }))
.toList(); .toList();
@ -129,8 +120,7 @@ var projected = await productTable
### Explicit Joins ### Explicit Joins
```javascript ```javascript
var orderLineTable = await db.query('LowCodeDemo.Orders.OrderLine'); var orderLines = await db.query('LowCodeDemo.Orders.OrderLine')
var orderLines = await orderLineTable
.join('LowCodeDemo.Products.Product', 'p', (ol, p) => ol.ProductId === p.Id) .join('LowCodeDemo.Products.Product', 'p', (ol, p) => ol.ProductId === p.Id)
.take(10) .take(10)
.toList(); .toList();
@ -145,8 +135,7 @@ orderLines.forEach(line => {
### Left Join ### Left Join
```javascript ```javascript
var orderTable = await db.query('LowCodeDemo.Orders.Order'); var orders = await db.query('LowCodeDemo.Orders.Order')
var orders = await orderTable
.leftJoin('LowCodeDemo.Products.Product', 'p', (o, p) => o.CustomerId === p.Id) .leftJoin('LowCodeDemo.Products.Product', 'p', (o, p) => o.CustomerId === p.Id)
.toList(); .toList();
@ -160,22 +149,18 @@ orders.forEach(order => {
### LINQ-Style Join ### LINQ-Style Join
```javascript ```javascript
var orderTable = await db.query('Order'); db.query('Order')
await orderTable.join( .join('LowCodeDemo.Products.Product',
'LowCodeDemo.Products.Product', o => o.ProductId,
o => o.ProductId, p => p.Id)
p => p.Id
);
``` ```
### Join with Filtered Query ### Join with Filtered Query
```javascript ```javascript
var productTable = await db.query('Product'); var expensiveProducts = db.query('Product').where(p => p.Price > 100);
var expensiveProducts = await productTable.where(p => p.Price > 100);
var orderLineTable = await db.query('OrderLine'); var orders = await db.query('OrderLine')
var orders = await orderLineTable
.join(expensiveProducts, .join(expensiveProducts,
ol => ol.ProductId, ol => ol.ProductId,
p => p.Id) p => p.Id)
@ -194,9 +179,8 @@ Set operations execute at the database level using SQL:
| `except(query)` | `EXCEPT` | Elements in first, not second | | `except(query)` | `EXCEPT` | Elements in first, not second |
```javascript ```javascript
var productTable = await db.query('Product'); var cheap = db.query('Product').where(x => x.Price <= 100);
var cheap = await productTable.where(x => x.Price <= 100); var popular = db.query('Product').where(x => x.Rating > 4);
var popular = await productTable.where(x => x.Rating > 4);
var bestDeals = await cheap.intersect(popular).toList(); var bestDeals = await cheap.intersect(popular).toList();
var underrated = await cheap.except(popular).toList(); var underrated = await cheap.except(popular).toList();
@ -216,17 +200,15 @@ All aggregations execute as SQL statements:
| `groupBy(x => x.Property)` | `GROUP BY ...` | `Promise<GroupResult[]>` | | `groupBy(x => x.Property)` | `GROUP BY ...` | `Promise<GroupResult[]>` |
```javascript ```javascript
var productTable = await db.query('Product'); var totalValue = await db.query('Product').sum(x => x.Price);
var totalValue = await productTable.sum(x => x.Price); var avgPrice = await db.query('Product').where(x => x.InStock).average(x => x.Price);
var avgPrice = await (await productTable.where(x => x.InStock)).average(x => x.Price); var cheapest = await db.query('Product').min(x => x.Price);
var cheapest = await productTable.min(x => x.Price);
``` ```
### GroupBy with Select ### GroupBy with Select
```javascript ```javascript
var productTable = await db.query('Product'); var grouped = await db.query('Product')
var grouped = await productTable
.groupBy(x => x.Category) .groupBy(x => x.Category)
.select(g => ({ .select(g => ({
Category: g.Key, Category: g.Key,
@ -255,8 +237,7 @@ var grouped = await productTable
### GroupBy with Items ### GroupBy with Items
```javascript ```javascript
var productTable = await db.query('Product'); var grouped = await db.query('Product')
var grouped = await productTable
.groupBy(x => x.Category) .groupBy(x => x.Category)
.select(g => ({ .select(g => ({
Category: g.Key, Category: g.Key,
@ -277,12 +258,11 @@ var grouped = await productTable
Math functions translate to SQL functions (ROUND, FLOOR, CEILING, ABS, etc.): Math functions translate to SQL functions (ROUND, FLOOR, CEILING, ABS, etc.):
```javascript ```javascript
var productTable = await db.query('Product'); var products = await db.query('Product')
var products = await productTable
.where(x => Math.round(x.Price) > 100) .where(x => Math.round(x.Price) > 100)
.toList(); .toList();
var result = await productTable var result = await db.query('Product')
.where(x => Math.abs(x.Balance) < 10 && Math.floor(x.Rating) >= 4) .where(x => Math.abs(x.Balance) < 10 && Math.floor(x.Rating) >= 4)
.toList(); .toList();
``` ```
@ -400,8 +380,7 @@ All values are parameterized:
```javascript ```javascript
var malicious = "'; DROP TABLE Products;--"; var malicious = "'; DROP TABLE Products;--";
// Safely treated as a literal string — no injection // Safely treated as a literal string — no injection
var entityTable = await db.query('Entity'); var result = await db.query('Entity').where(x => x.Name.includes(malicious)).count();
var result = await (await entityTable.where(x => x.Name.includes(malicious))).count();
``` ```
### Blocked Features ### Blocked Features
@ -418,8 +397,7 @@ if (!context.commandArgs.getValue('Email').includes('@')) {
// Try-catch for safe execution // Try-catch for safe execution
try { try {
var entityTable = await db.query('Entity'); var products = await db.query('Entity').where(x => x.Price > 0).toList();
var products = await entityTable.where(x => x.Price > 0).toList();
} catch (error) { } catch (error) {
context.log('Query failed: ' + error.message); context.log('Query failed: ' + error.message);
} }
@ -444,8 +422,7 @@ try {
var productId = context.commandArgs.getValue('ProductId'); var productId = context.commandArgs.getValue('ProductId');
var quantity = context.commandArgs.getValue('Quantity'); var quantity = context.commandArgs.getValue('Quantity');
var productTable = await db.query('LowCodeDemo.Products.Product'); var product = await db.query('LowCodeDemo.Products.Product')
var product = await productTable
.where(x => x.Id === productId) .where(x => x.Id === productId)
.first(); .first();
@ -458,10 +435,11 @@ context.commandArgs.setValue('TotalAmount', product.Price * quantity);
### Sales Dashboard (Custom Endpoint) ### Sales Dashboard (Custom Endpoint)
```javascript ```javascript
var orderTable = await db.query('LowCodeDemo.Orders.Order'); var totalOrders = await db.query('LowCodeDemo.Orders.Order').count();
var totalOrders = await orderTable.count(); var delivered = await db.query('LowCodeDemo.Orders.Order')
var delivered = await (await orderTable.where(x => x.IsDelivered === true)).count(); .where(x => x.IsDelivered === true).count();
var revenue = await (await orderTable.where(x => x.IsDelivered === true)).sum(x => x.TotalAmount); var revenue = await db.query('LowCodeDemo.Orders.Order')
.where(x => x.IsDelivered === true).sum(x => x.TotalAmount);
return ok({ return ok({
orders: totalOrders, orders: totalOrders,

10
docs/en/package-version-changes.md

@ -1,5 +1,15 @@
# Package Version Changes # Package Version Changes
## 10.3.0-preview
| Package | Old Version | New Version | PR |
|---------|-------------|-------------|-----|
| OpenIddict.Abstractions | 7.2.0 | 7.3.0 | #25053 |
| OpenIddict.Core | 7.2.0 | 7.3.0 | #25053 |
| OpenIddict.Server.AspNetCore | 7.2.0 | 7.3.0 | #25053 |
| OpenIddict.Validation.AspNetCore | 7.2.0 | 7.3.0 | #25053 |
| OpenIddict.Validation.ServerIntegration | 7.2.0 | 7.3.0 | #25053 |
## 10.2.0-rc.1 ## 10.2.0-rc.1
| Package | Old Version | New Version | PR | | Package | Old Version | New Version | PR |

BIN
docs/en/tutorials/modular-crm/images/abp-studio-add-new-standard-module-ui-dialog-blazor-webapp.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
docs/en/tutorials/modular-crm/images/abp-studio-add-package-reference-dialog-3-blazor-webapp.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
docs/en/tutorials/modular-crm/images/abp-studio-catalog-module-expanded-in-solution-explorer-blazor-webapp.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
docs/en/tutorials/modular-crm/images/abp-studio-imports-and-dependencies-v2-blazor-webapp.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
docs/en/tutorials/modular-crm/images/abp-studio-install-module-dialog-blazor-webapp.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
docs/en/tutorials/modular-crm/images/abp-studio-install-module-dialog-for-ordering-v2-blazor-webapp.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
docs/en/tutorials/modular-crm/images/abp-studio-modular-crm-with-standard-module-blazor-webapp.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
docs/en/tutorials/modular-crm/images/abp-studio-module-installation-dialog-for-catalog-blazor-webapp.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
docs/en/tutorials/modular-crm/images/catalog-module-vs-code-blazor-webapp.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
docs/en/tutorials/modular-crm/images/vscode-catalog-index-razor-blazor-webapp.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

7
docs/en/tutorials/modular-crm/index.md

@ -14,6 +14,13 @@
} }
``` ```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
}
````
````json ````json
//[doc-nav] //[doc-nav]
{ {

15
docs/en/tutorials/modular-crm/part-01.md

@ -14,6 +14,13 @@
} }
``` ```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
}
````
````json ````json
//[doc-nav] //[doc-nav]
{ {
@ -35,7 +42,7 @@ In this first part of this tutorial, we will create a new ABP solution with modu
Follow the *[Get Started](../../get-started/single-layer-web-application.md)* guide to create a single layer web application with the following configuration: Follow the *[Get Started](../../get-started/single-layer-web-application.md)* guide to create a single layer web application with the following configuration:
* **Solution name**: `ModularCrm` * **Solution name**: `ModularCrm`
* **UI Framework**: {{if UI == "MVC"}}ASP.NET Core MVC / Razor Pages{{else if UI == "NG"}}Angular{{end}} * **UI Framework**: {{if UI == "MVC"}}ASP.NET Core MVC / Razor Pages{{else if UI == "BlazorWebApp"}}Blazor WebApp{{end}}
* **Database Provider**: Entity Framework Core * **Database Provider**: Entity Framework Core
{{if UI == "NG"}}> **Note:** Angular users can continue with the Angular UI steps in the upcoming parts while following the same modularity flow. {{if UI == "NG"}}> **Note:** Angular users can continue with the Angular UI steps in the upcoming parts while following the same modularity flow.
@ -72,12 +79,16 @@ Initially, you see a `ModularCrm` solution with two solution folders:
If you expand it, you can see the .NET projects (ABP Studio Packages) of the `ModularCrm.Catalog` module: If you expand it, you can see the .NET projects (ABP Studio Packages) of the `ModularCrm.Catalog` module:
{{if UI == "MVC"}}
![abp-studio-catalog-module-expanded-in-solution-explorer](images/abp-studio-catalog-module-expanded-in-solution-explorer.png) ![abp-studio-catalog-module-expanded-in-solution-explorer](images/abp-studio-catalog-module-expanded-in-solution-explorer.png)
{{else if UI == "BlazorWebApp"}}
![abp-studio-catalog-module-expanded-in-solution-explorer](images/abp-studio-catalog-module-expanded-in-solution-explorer-blazor-webapp.png)
{{end}}
- `ModularCrm.Catalog`: The main module project that contains your [entities](../../framework/architecture/domain-driven-design/entities.md), [application service](../../framework/architecture/domain-driven-design/application-services.md) implementations and other business objects - `ModularCrm.Catalog`: The main module project that contains your [entities](../../framework/architecture/domain-driven-design/entities.md), [application service](../../framework/architecture/domain-driven-design/application-services.md) implementations and other business objects
- `ModularCrm.Catalog.Contracts`: Basically contains [application service](../../framework/architecture/domain-driven-design/application-services.md) interfaces and [DTOs](../../framework/architecture/domain-driven-design/data-transfer-objects.md). These interfaces then can be used by client modules for integration purposes or by the user interface to perform use cases related to that module - `ModularCrm.Catalog.Contracts`: Basically contains [application service](../../framework/architecture/domain-driven-design/application-services.md) interfaces and [DTOs](../../framework/architecture/domain-driven-design/data-transfer-objects.md). These interfaces then can be used by client modules for integration purposes or by the user interface to perform use cases related to that module
- `ModularCrm.Catalog.Tests`: Unit and integration tests (if you selected the _Include Tests_ option) for that module - `ModularCrm.Catalog.Tests`: Unit and integration tests (if you selected the _Include Tests_ option) for that module
- `ModularCrm.Catalog.UI`: Contains user interface pages and components for the module - {{if UI == "MVC"}}`ModularCrm.Catalog.UI`: Contains user interface pages and components for the module{{else if UI == "BlazorWebApp"}}`ModularCrm.Catalog.Blazor`: Contains Blazor WebApp user interface pages and components for the module{{end}}
## Summary ## Summary

15
docs/en/tutorials/modular-crm/part-02.md

@ -14,6 +14,13 @@
} }
``` ```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
}
````
````json ````json
//[doc-nav] //[doc-nav]
{ {
@ -32,7 +39,7 @@ In this part, you will install the `ModularCrm.Catalog` module to the main appli
## Installing the Catalog Module to the Main Application ## Installing the Catalog Module to the Main Application
A module does not contain an executable application inside. The `ModularCrm.Catalog.UI` project is just a class library project, not an executable web application. A module should be installed in an executable application to run it. A module does not contain an executable application inside. The {{if UI == "MVC"}}`ModularCrm.Catalog.UI`{{else if UI == "BlazorWebApp"}}`ModularCrm.Catalog.Blazor`{{end}} project is just a class library project, not an executable web application. A module should be installed in an executable application to run it.
> **Ensure that the web application is not running in [Solution Runner](../../studio/running-applications.md) or in your IDE. Installing a module to a running application will produce errors.** > **Ensure that the web application is not running in [Solution Runner](../../studio/running-applications.md) or in your IDE. Installing a module to a running application will produce errors.**
@ -48,9 +55,13 @@ Select the `ModularCrm.Catalog` module and check the *Install this module* optio
When you click the *OK* button, ABP Studio opens the *Install Module* dialog: When you click the *OK* button, ABP Studio opens the *Install Module* dialog:
{{if UI == "MVC"}}
![abp-studio-module-installation-dialog-for-catalog](images/abp-studio-module-installation-dialog-for-catalog.png) ![abp-studio-module-installation-dialog-for-catalog](images/abp-studio-module-installation-dialog-for-catalog.png)
{{else if UI == "BlazorWebApp"}}
![abp-studio-module-installation-dialog-for-catalog](images/abp-studio-module-installation-dialog-for-catalog-blazor-webapp.png)
{{end}}
Select the `ModularCrm.Catalog` and `ModularCrm.Catalog.UI` packages from the left area and ensure the `ModularCrm` package from the middle area was checked as shown in the preceding figure. Finally, click _OK_. Select the `ModularCrm.Catalog` and {{if UI == "MVC"}}`ModularCrm.Catalog.UI`{{else if UI == "BlazorWebApp"}}`ModularCrm.Catalog.Blazor`{{end}} packages from the left area. {{if UI == "MVC"}}Ensure `ModularCrm` was checked in the middle area as shown in the preceding figure.{{else if UI == "BlazorWebApp"}}For `ModularCrm.Catalog`, ensure `ModularCrm` is checked. For `ModularCrm.Catalog.Blazor`, ensure both `ModularCrm` and `ModularCrm.Client` are checked in the middle area as shown in the preceding figure.{{end}} Finally, click _OK_.
## Building the Main Application ## Building the Main Application

90
docs/en/tutorials/modular-crm/part-03.md

@ -14,6 +14,13 @@
} }
``` ```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
}
````
````json ````json
//[doc-nav] //[doc-nav]
{ {
@ -42,7 +49,11 @@ Open the `ModularCrm.Catalog` module in your favorite IDE. You can right-click t
The `ModularCrm.Catalog` .NET solution should look like the following figure: The `ModularCrm.Catalog` .NET solution should look like the following figure:
{{if UI == "MVC"}}
![catalog-module-vs-code](images/catalog-module-vs-code.png) ![catalog-module-vs-code](images/catalog-module-vs-code.png)
{{else if UI == "BlazorWebApp"}}
![catalog-module-vs-code](images/catalog-module-vs-code-blazor-webapp.png)
{{end}}
Add a new `Product` class under the `ModularCrm.Catalog` project: Add a new `Product` class under the `ModularCrm.Catalog` project:
@ -351,6 +362,8 @@ public partial class ProductToProductDtoMapper : MapperBase<Product, ProductDto>
### Exposing Application Services as HTTP API Controllers ### Exposing Application Services as HTTP API Controllers
{{if UI == "MVC"}}
> This application doesn't need to expose any functionality as HTTP API, because all the module integration and communication will be done in the same process as a natural aspect of a monolith modular application. However, in this section, we will create HTTP APIs because; > This application doesn't need to expose any functionality as HTTP API, because all the module integration and communication will be done in the same process as a natural aspect of a monolith modular application. However, in this section, we will create HTTP APIs because;
> >
> 1. We will use these HTTP API endpoints in development to create some example data. > 1. We will use these HTTP API endpoints in development to create some example data.
@ -358,6 +371,8 @@ public partial class ProductToProductDtoMapper : MapperBase<Product, ProductDto>
> >
> So, follow the instructions in this section and expose the product application service as an HTTP API endpoint. > So, follow the instructions in this section and expose the product application service as an HTTP API endpoint.
{{end}}
To create HTTP API endpoints for the catalog module, you have two options: To create HTTP API endpoints for the catalog module, you have two options:
* You can create a regular ASP.NET Core Controller class in the `ModularCrm.Catalog` project, inject `IProductAppService` and create wrapper methods for each public method of the product application service. You will do this later while you create the Ordering module. (Also, you can check the `SampleController` class under the **Samples** folder in the `ModularCrm.Catalog` project for an example) * You can create a regular ASP.NET Core Controller class in the `ModularCrm.Catalog` project, inject `IProductAppService` and create wrapper methods for each public method of the product application service. You will do this later while you create the Ordering module. (Also, you can check the `SampleController` class under the **Samples** folder in the `ModularCrm.Catalog` project for an example)
@ -379,6 +394,34 @@ This will tell the ABP framework to create API controllers for the application s
Now, ABP will automatically expose the application services defined in the `ModularCrm.Catalog` project as API controllers. The next section will use these API controllers to create some example products. Now, ABP will automatically expose the application services defined in the `ModularCrm.Catalog` project as API controllers. The next section will use these API controllers to create some example products.
{{if UI == "BlazorWebApp"}}
### Configuring Client Proxies for the Catalog Module
Since the Blazor WebApp template has a separate `ModularCrm.Client` project, configure HTTP client proxies for the Catalog contracts in the `ModularCrmClientModule` class:
````csharp
using ModularCrm.Catalog;
[DependsOn(
typeof(CatalogContractsModule)
// ...other dependencies
)]
public class ModularCrmClientModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
...
context.Services.AddHttpClientProxies(typeof(ModularCrmContractsModule).Assembly);
context.Services.AddHttpClientProxies(typeof(CatalogContractsModule).Assembly); // NEW: ADD HttpClientProxies
}
}
````
Also ensure the `ModularCrm.Catalog.Blazor` package is installed for both the `ModularCrm` and `ModularCrm.Client` projects.
{{end}}
### Creating Example Products ### Creating Example Products
This section will create a few example products using the [Swagger UI](../../framework/api-development/swagger.md). Thus, you will have some sample products to show on the UI. This section will create a few example products using the [Swagger UI](../../framework/api-development/swagger.md). Thus, you will have some sample products to show on the UI.
@ -415,6 +458,8 @@ As a first step, you can stop the application on ABP Studio's Solution Runner if
### Creating the Products Page ### Creating the Products Page
{{if UI == "MVC"}}
Open the `ModularCrm.Catalog` .NET solution in your IDE, and find the `Pages/Catalog/Index.cshtml` file under the `ModularCrm.Catalog.UI` project: Open the `ModularCrm.Catalog` .NET solution in your IDE, and find the `Pages/Catalog/Index.cshtml` file under the `ModularCrm.Catalog.UI` project:
![vscode-catalog-cshtml](images/vscode-catalog-cshtml.png) ![vscode-catalog-cshtml](images/vscode-catalog-cshtml.png)
@ -470,7 +515,50 @@ Here, you simply use the `IProductAppService` to get a list of all products and
</abp-card> </abp-card>
```` ````
Right-click the `ModularCrm` application on ABP Studio's solution runner and select the *Start* command: {{else if UI == "BlazorWebApp"}}
Open the `ModularCrm.Catalog` .NET solution in your IDE, and find the `Pages/Catalog/Index.razor` file under the `ModularCrm.Catalog.Blazor` project.
![vscode-catalog-index-razor-blazor-webapp](images/vscode-catalog-index-razor-blazor-webapp.png)
Replace the `Index.razor` file with the following content:
````razor
@page "/catalog"
@using System.Collections.Generic
@using System.Threading.Tasks
@using ModularCrm.Catalog
@inject IProductAppService ProductAppService
<h1>Products</h1>
<Card>
<CardBody>
<ListGroup>
@foreach (var product in Products)
{
<ListGroupItem>
@product.Name <span class="text-muted">(stock: @product.StockCount)</span>
</ListGroupItem>
}
</ListGroup>
</CardBody>
</Card>
@code {
private List<ProductDto> Products { get; set; } = new();
protected override async Task OnInitializedAsync()
{
Products = await ProductAppService.GetListAsync();
}
}
````
Here, you inject `IProductAppService`, get all products in `OnInitializedAsync`, and then render the result in a simple list.
{{end}}
Right-click the `ModularCrm` application on ABP Studio's Solution Runner and select the *Start* command:
![abp-studio-build-and-restart-application](images/abp-studio-build-and-restart-application.png) ![abp-studio-build-and-restart-application](images/abp-studio-build-and-restart-application.png)

23
docs/en/tutorials/modular-crm/part-04.md

@ -14,6 +14,13 @@
} }
``` ```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
}
````
````json ````json
//[doc-nav] //[doc-nav]
{ {
@ -47,10 +54,12 @@ That command opens a dialog to define the properties of the new module:
Set `ModularCrm.Ordering` as the *Module name*, leave the *Output folder* as is and click the *Next* button. Set `ModularCrm.Ordering` as the *Module name*, leave the *Output folder* as is and click the *Next* button.
{{if UI == "MVC"}} {{if UI == "MVC"}}
![abp-studio-add-new-standard-module-ui-dialog](images/abp-studio-add-new-standard-module-ui-dialog.png) ![abp-studio-add-new-standard-module-ui-dialog](images/abp-studio-add-new-standard-module-ui-dialog.png)
{{else if UI == "BlazorWebApp"}}
![abp-studio-add-new-standard-module-ui-dialog](images/abp-studio-add-new-standard-module-ui-dialog-blazor-webapp.png)
{{end}}
You can choose the type of UI you want to support in your module or select *No UI* if you don't need a user interface. In this example, we'll select the *MVC* option and click *Next*. You can choose the type of UI you want to support in your module or select *No UI* if you don't need a user interface. In this example, we'll select the {{if UI == "MVC"}}*MVC*{{else if UI == "BlazorWebApp"}}*Blazor WebApp*{{end}} option and click *Next*.
{{else if UI == "NG"}} {{else if UI == "NG"}}
@ -68,7 +77,11 @@ You can include or not include unit tests for the new module here. We are unchec
Here is the final solution structure after adding the `ModularCrm.Ordering` module: Here is the final solution structure after adding the `ModularCrm.Ordering` module:
{{if UI == "MVC"}}
![abp-studio-modular-crm-with-standard-module](images/abp-studio-modular-crm-with-standard-module.png) ![abp-studio-modular-crm-with-standard-module](images/abp-studio-modular-crm-with-standard-module.png)
{{else if UI == "BlazorWebApp"}}
![abp-studio-modular-crm-with-standard-module](images/abp-studio-modular-crm-with-standard-module-blazor-webapp.png)
{{end}}
## Installing into the Main Application ## Installing into the Main Application
@ -86,9 +99,13 @@ That command opens the *Import Module* dialog:
Select the `ModularCrm.Ordering` module and check the *Install this module* option as shown in the preceding figure. When you click the OK button, a new dialog is shown to select the packages to install: Select the `ModularCrm.Ordering` module and check the *Install this module* option as shown in the preceding figure. When you click the OK button, a new dialog is shown to select the packages to install:
{{if UI == "MVC"}}
![abp-studio-install-module-dialog](images/abp-studio-install-module-dialog-v2.png) ![abp-studio-install-module-dialog](images/abp-studio-install-module-dialog-v2.png)
{{else if UI == "BlazorWebApp"}}
![abp-studio-install-module-dialog](images/abp-studio-install-module-dialog-blazor-webapp.png)
{{end}}
Select the `ModularCrm.Ordering` and `ModularCrm.Ordering.UI` packages from the left area and ensure the `ModularCrm` package from the middle area was checked as shown in the preceding figure. Finally, click _OK_. Select the `ModularCrm.Ordering` and {{if UI == "MVC"}}`ModularCrm.Ordering.UI`{{else if UI == "BlazorWebApp"}}`ModularCrm.Ordering.Blazor`{{end}} packages from the left area. {{if UI == "MVC"}}Ensure `ModularCrm` was checked in the middle area as shown in the preceding figure.{{else if UI == "BlazorWebApp"}}For `ModularCrm.Ordering`, ensure `ModularCrm` is checked. For `ModularCrm.Ordering.Blazor`, ensure both `ModularCrm` and `ModularCrm.Client` are checked in the middle area as shown in the preceding figure.{{end}} Finally, click _OK_.
{{if UI == "NG"}} {{if UI == "NG"}}

122
docs/en/tutorials/modular-crm/part-05.md

@ -14,6 +14,13 @@
} }
``` ```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
}
````
````json ````json
//[doc-nav] //[doc-nav]
{ {
@ -364,6 +371,35 @@ Configure<AbpAspNetCoreMvcOptions>(options =>
This will tell the ABP framework to create API controllers for the application services in the `ModularCrm.Ordering` assembly. This will tell the ABP framework to create API controllers for the application services in the `ModularCrm.Ordering` assembly.
{{if UI == "BlazorWebApp"}}
### Configuring Client Proxies for the Ordering Module
In the `ModularCrm.Client` project, configure HTTP client proxies for the Ordering contracts in the `ModularCrmClientModule` class:
````csharp
using ModularCrm.Ordering;
[DependsOn(
typeof(OrderingContractsModule)
// ...other dependencies
)]
public class ModularCrmClientModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
...
context.Services.AddHttpClientProxies(typeof(ModularCrmContractsModule).Assembly);
context.Services.AddHttpClientProxies(typeof(CatalogContractsModule).Assembly);
context.Services.AddHttpClientProxies(typeof(OrderingContractsModule).Assembly); // NEW: ADD HttpClientProxies
}
}
````
Also ensure the `ModularCrm.Ordering.Blazor` package is installed for both the `ModularCrm` and `ModularCrm.Client` projects.
{{end}}
### Creating Example Orders ### Creating Example Orders
This section will create a few example orders using the [Swagger UI](../../framework/api-development/swagger.md). Thus, you will have some sample orders to show on the UI. This section will create a few example orders using the [Swagger UI](../../framework/api-development/swagger.md). Thus, you will have some sample orders to show on the UI.
@ -394,6 +430,8 @@ As a first step, you can stop the application on ABP Studio's Solution Runner if
### Creating the Orders Page ### Creating the Orders Page
{{if UI == "MVC"}}
Replace the `Index.cshtml.cs` content in the `Pages/Ordering` folder of the `ModularCrm.Ordering.UI` project with the following code block: Replace the `Index.cshtml.cs` content in the `Pages/Ordering` folder of the `ModularCrm.Ordering.UI` project with the following code block:
````csharp ````csharp
@ -490,6 +528,90 @@ public class OrderingMenuContributor : IMenuContributor
> You can check the [menu documentation](../../framework/ui/mvc-razor-pages/navigation-menu.md) to learn more about manipulating menu items. > You can check the [menu documentation](../../framework/ui/mvc-razor-pages/navigation-menu.md) to learn more about manipulating menu items.
{{else if UI == "BlazorWebApp"}}
Replace the `Index.razor` content in the `Pages/Ordering` folder of the `ModularCrm.Ordering.Blazor` project with the following code block:
````razor
@page "/ordering"
@using System.Collections.Generic
@using System.Threading.Tasks
@using ModularCrm.Ordering
@inject IOrderAppService OrderAppService
<h1>Orders</h1>
<Card>
<CardBody>
<ListGroup>
@foreach (var order in Orders)
{
<ListGroupItem>
<strong>Customer:</strong> @order.CustomerName <br />
<strong>Product:</strong> @order.ProductId <br />
<strong>State:</strong> @order.State
</ListGroupItem>
}
</ListGroup>
</CardBody>
</Card>
@code {
private List<OrderDto> Orders { get; set; } = new();
protected override async Task OnInitializedAsync()
{
Orders = await OrderAppService.GetListAsync();
}
}
````
This page shows a list of orders on the UI. You haven't created a UI to create new orders, and we will not do it to keep this tutorial simple. If you want to learn how to create advanced UIs with ABP, please follow the [Book Store tutorial](../book-store/index.md).
### Editing the Menu Item
ABP provides a modular navigation [menu system](../../framework/ui/blazor/navigation-menu.md) where each module can contribute to the main menu dynamically.
Edit the `OrderingMenuContributor` class in the `ModularCrm.Ordering.Blazor` project:
````csharp
using System.Threading.Tasks;
using Volo.Abp.UI.Navigation;
namespace ModularCrm.Ordering.Blazor.Menus;
public class OrderingMenuContributor : IMenuContributor
{
public async Task ConfigureMenuAsync(MenuConfigurationContext context)
{
if (context.Menu.Name == StandardMenus.Main)
{
await ConfigureMainMenuAsync(context);
}
}
private Task ConfigureMainMenuAsync(MenuConfigurationContext context)
{
context.Menu.AddItem(
new ApplicationMenuItem(
OrderingMenus.Prefix, // Unique menu id
"Orders", // Menu display text
"/ordering", // URL
"fa-solid fa-basket-shopping" // Icon CSS class
)
);
return Task.CompletedTask;
}
}
````
`OrderingMenuContributor` implements the `IMenuContributor` interface, which forces us to implement the `ConfigureMenuAsync` method. In that method, you can manipulate the menu items (add new menu items, remove existing menu items or change the properties of existing menu items). The `ConfigureMenuAsync` method is executed whenever the menu is rendered on the UI, so you can dynamically decide how to manipulate the menu items.
> You can check the [menu documentation](../../framework/ui/blazor/navigation-menu.md) to learn more about manipulating menu items.
{{end}}
### Building the Application ### Building the Application
Now, you will run the application to see the result. Please stop the application if it is already running. Then open the *Solution Runner* panel, right-click the `ModularCrm` application, and select the *Build* -> *Graph Build* command: Now, you will run the application to see the result. Please stop the application if it is already running. Then open the *Solution Runner* panel, right-click the `ModularCrm` application, and select the *Build* -> *Graph Build* command:

53
docs/en/tutorials/modular-crm/part-06.md

@ -14,6 +14,13 @@
} }
``` ```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
}
````
````json ````json
//[doc-nav] //[doc-nav]
{ {
@ -145,7 +152,11 @@ Open the ABP Studio UI and stop the application if it is already running. Then o
In the opening dialog, select the *This solution* tab, find and check the `ModularCrm.Catalog.Contracts` package and click the OK button: In the opening dialog, select the *This solution* tab, find and check the `ModularCrm.Catalog.Contracts` package and click the OK button:
{{if UI == "MVC"}}
![abp-studio-add-package-reference-dialog-3](images/abp-studio-add-package-reference-dialog-3.png) ![abp-studio-add-package-reference-dialog-3](images/abp-studio-add-package-reference-dialog-3.png)
{{else if UI == "BlazorWebApp"}}
![abp-studio-add-package-reference-dialog-3](images/abp-studio-add-package-reference-dialog-3-blazor-webapp.png)
{{end}}
ABP Studio adds the package reference and arranges the [module](../../framework/architecture/modularity/basics.md) dependency. ABP Studio adds the package reference and arranges the [module](../../framework/architecture/modularity/basics.md) dependency.
@ -258,7 +269,7 @@ Let's see what we've changed:
{{if UI == "MVC"}} {{if UI == "MVC"}}
Open the `Index.cshtml` file, and change the `@order.ProductId` part by `@order.ProductName` to write the product name instead of the product ID. The final `Index.cshtml` content should be the following: Open the `Index.cshtml` file, and change the `@order.ProductId` part to `@order.ProductName` to write the product name instead of the product ID. The final `Index.cshtml` content should be the following:
````html ````html
@page @page
@ -282,6 +293,46 @@ Open the `Index.cshtml` file, and change the `@order.ProductId` part by `@order.
</abp-card> </abp-card>
```` ````
{{else if UI == "BlazorWebApp"}}
Open the `Index.razor` file, and change the `@order.ProductId` part to `@order.ProductName` to write the product name instead of the product ID. The final `Index.razor` content should be the following:
````razor
@page "/ordering"
@using System.Collections.Generic
@using System.Threading.Tasks
@using ModularCrm.Ordering
@inject IOrderAppService OrderAppService
<h1>Orders</h1>
<Card>
<CardBody>
<ListGroup>
@foreach (var order in Orders)
{
<ListGroupItem>
<strong>Customer:</strong> @order.CustomerName <br />
<strong>Product:</strong> @order.ProductName <br />
<strong>State:</strong> @order.State
</ListGroupItem>
}
</ListGroup>
</CardBody>
</Card>
@code {
private List<OrderDto> Orders { get; set; } = new();
protected override async Task OnInitializedAsync()
{
Orders = await OrderAppService.GetListAsync();
}
}
````
{{end}}
That's all. Now, you can graph build the main application and run it in ABP Studio to see the result: That's all. Now, you can graph build the main application and run it in ABP Studio to see the result:
![abp-studio-browser-list-of-orders-with-product-name](images/abp-studio-browser-list-of-orders-with-product-name.png) ![abp-studio-browser-list-of-orders-with-product-name](images/abp-studio-browser-list-of-orders-with-product-name.png)

15
docs/en/tutorials/modular-crm/part-07.md

@ -14,6 +14,13 @@
} }
``` ```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
}
````
````json ````json
//[doc-nav] //[doc-nav]
{ {
@ -165,13 +172,21 @@ In the opening dialog, find and select the `ModularCrm.Ordering` module, check t
Once you click the OK button, the Ordering module is imported to the Catalog module, and an installation dialog is open: Once you click the OK button, the Ordering module is imported to the Catalog module, and an installation dialog is open:
{{if UI == "MVC"}}
![abp-studio-install-module-dialog-for-ordering](images/abp-studio-install-module-dialog-for-ordering-v2.png) ![abp-studio-install-module-dialog-for-ordering](images/abp-studio-install-module-dialog-for-ordering-v2.png)
{{else if UI == "BlazorWebApp"}}
![abp-studio-install-module-dialog-for-ordering](images/abp-studio-install-module-dialog-for-ordering-v2-blazor-webapp.png)
{{end}}
Here, select the `ModularCrm.Ordering.Contracts` package on the left side (because we want to add that package reference) and `ModularCrm.Catalog` package on the middle area (because we want to add the package reference to that project). Also, select the `ModularCrm.Ordering` package on the right side, and unselect all packages on the middle area (we don't need the implementation or any other packages). Then, click the OK button to finish the installation operation. Here, select the `ModularCrm.Ordering.Contracts` package on the left side (because we want to add that package reference) and `ModularCrm.Catalog` package on the middle area (because we want to add the package reference to that project). Also, select the `ModularCrm.Ordering` package on the right side, and unselect all packages on the middle area (we don't need the implementation or any other packages). Then, click the OK button to finish the installation operation.
You can check the ABP Studio's *Solution Explorer* panel to see the module import and the project reference (dependency). You can check the ABP Studio's *Solution Explorer* panel to see the module import and the project reference (dependency).
{{if UI == "MVC"}}
![abp-studio-imports-and-dependencies](images/abp-studio-imports-and-dependencies-v2.png) ![abp-studio-imports-and-dependencies](images/abp-studio-imports-and-dependencies-v2.png)
{{else if UI == "BlazorWebApp"}}
![abp-studio-imports-and-dependencies](images/abp-studio-imports-and-dependencies-v2-blazor-webapp.png)
{{end}}
### Handling the `OrderPlacedEto` Event ### Handling the `OrderPlacedEto` Event

9
docs/en/tutorials/modular-crm/part-08.md

@ -14,6 +14,13 @@
# Integrating the Modules: Joining the Products and Orders Data # Integrating the Modules: Joining the Products and Orders Data
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
}
````
````json ````json
//[doc-nav] //[doc-nav]
{ {
@ -176,7 +183,7 @@ Now, you know the fundamental principles and mechanics of building sophisticated
## Download the Source Code ## Download the Source Code
You can download the completed sample solution [here](https://github.com/abpframework/abp-samples/tree/master/ModularCRM). You can download the completed sample solution [here](https://github.com/abpframework/abp-samples/tree/master/ModularCRM-BlazorWebApp).
## See Also ## See Also

2
framework/Volo.Abp.slnx

@ -169,6 +169,7 @@
<Project Path="src/Volo.Abp.TickerQ/Volo.Abp.TickerQ.csproj" /> <Project Path="src/Volo.Abp.TickerQ/Volo.Abp.TickerQ.csproj" />
<Project Path="src/Volo.Abp.BackgroundJobs.TickerQ/Volo.Abp.BackgroundJobs.TickerQ.csproj" /> <Project Path="src/Volo.Abp.BackgroundJobs.TickerQ/Volo.Abp.BackgroundJobs.TickerQ.csproj" />
<Project Path="src/Volo.Abp.BackgroundWorkers.TickerQ/Volo.Abp.BackgroundWorkers.TickerQ.csproj" /> <Project Path="src/Volo.Abp.BackgroundWorkers.TickerQ/Volo.Abp.BackgroundWorkers.TickerQ.csproj" />
<Project Path="src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj" />
</Folder> </Folder>
<Folder Name="/test/"> <Folder Name="/test/">
<Project Path="test/AbpTestBase/AbpTestBase.csproj" /> <Project Path="test/AbpTestBase/AbpTestBase.csproj" />
@ -256,5 +257,6 @@
<Project Path="test/Volo.Abp.Uow.Tests/Volo.Abp.Uow.Tests.csproj" /> <Project Path="test/Volo.Abp.Uow.Tests/Volo.Abp.Uow.Tests.csproj" />
<Project Path="test/Volo.Abp.Validation.Tests/Volo.Abp.Validation.Tests.csproj" /> <Project Path="test/Volo.Abp.Validation.Tests/Volo.Abp.Validation.Tests.csproj" />
<Project Path="test/Volo.Abp.VirtualFileSystem.Tests/Volo.Abp.VirtualFileSystem.Tests.csproj" /> <Project Path="test/Volo.Abp.VirtualFileSystem.Tests/Volo.Abp.VirtualFileSystem.Tests.csproj" />
<Project Path="test/Volo.Abp.OperationRateLimiting.Tests/Volo.Abp.OperationRateLimiting.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>

2
framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/AbpAspNetCoreAbstractionsModule.cs

@ -10,6 +10,6 @@ public class AbpAspNetCoreAbstractionsModule : AbpModule
public override void ConfigureServices(ServiceConfigurationContext context) public override void ConfigureServices(ServiceConfigurationContext context)
{ {
context.Services.AddSingleton<IWebContentFileProvider, NullWebContentFileProvider>(); context.Services.AddSingleton<IWebContentFileProvider, NullWebContentFileProvider>();
context.Services.AddSingleton<IWebClientInfoProvider, NullWebClientInfoProvider>();; context.Services.AddSingleton<IWebClientInfoProvider, NullWebClientInfoProvider>();
} }
} }

7
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController.cs

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.Http.Modeling; using Volo.Abp.Http.Modeling;
namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; namespace Volo.Abp.AspNetCore.Mvc.ApiExploring;
@ -16,8 +17,8 @@ public class AbpApiDefinitionController : AbpController, IRemoteService
} }
[HttpGet] [HttpGet]
public virtual ApplicationApiDescriptionModel Get(ApplicationApiDescriptionModelRequestDto model) public virtual async Task<ApplicationApiDescriptionModel> Get(ApplicationApiDescriptionModelRequestDto model)
{ {
return ModelProvider.CreateApiModel(model); return await ModelProvider.CreateApiModelAsync(model);
} }
} }

22
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/IXmlDocumentationProvider.cs

@ -0,0 +1,22 @@
using System;
using System.Reflection;
using System.Threading.Tasks;
namespace Volo.Abp.AspNetCore.Mvc.ApiExploring;
public interface IXmlDocumentationProvider
{
Task<string?> GetSummaryAsync(Type type);
Task<string?> GetRemarksAsync(Type type);
Task<string?> GetSummaryAsync(MethodInfo method);
Task<string?> GetRemarksAsync(MethodInfo method);
Task<string?> GetReturnsAsync(MethodInfo method);
Task<string?> GetParameterSummaryAsync(MethodInfo method, string parameterName);
Task<string?> GetSummaryAsync(PropertyInfo property);
}

231
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs

@ -0,0 +1,231 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using System.Xml.XPath;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.AspNetCore.Mvc.ApiExploring;
public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDependency
{
public ILogger<XmlDocumentationProvider> Logger { get; set; }
public XmlDocumentationProvider()
{
Logger = NullLogger<XmlDocumentationProvider>.Instance;
}
private static readonly Regex WhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
// Matches any remaining XML tags like <c>, <code>, <para>, <b>, etc.
private static readonly Regex XmlTagRegex = new(@"<[^>]+>", RegexOptions.Compiled);
// Matches <see cref="T:Foo.Bar"/>, <see langword="null"/>, <paramref name="x"/>, <typeparamref name="T"/>
private static readonly Regex XmlRefTagRegex = new(
@"<(see|paramref|typeparamref)\s+(cref|name|langword)=""([TMFPE]:)?(?<display>[^""]+)""\s*/?>",
RegexOptions.Compiled);
private readonly ConcurrentDictionary<Assembly, Lazy<Task<XDocument?>>> _xmlDocCache = new();
public virtual async Task<string?> GetSummaryAsync(Type type)
{
var memberName = GetMemberNameForType(type);
return await GetDocumentationElementAsync(type.Assembly, memberName, "summary");
}
public virtual async Task<string?> GetRemarksAsync(Type type)
{
var memberName = GetMemberNameForType(type);
return await GetDocumentationElementAsync(type.Assembly, memberName, "remarks");
}
public virtual async Task<string?> GetSummaryAsync(MethodInfo method)
{
var memberName = GetMemberNameForMethod(method);
return await GetDocumentationElementAsync(method.DeclaringType!.Assembly, memberName, "summary");
}
public virtual async Task<string?> GetRemarksAsync(MethodInfo method)
{
var memberName = GetMemberNameForMethod(method);
return await GetDocumentationElementAsync(method.DeclaringType!.Assembly, memberName, "remarks");
}
public virtual async Task<string?> GetReturnsAsync(MethodInfo method)
{
var memberName = GetMemberNameForMethod(method);
return await GetDocumentationElementAsync(method.DeclaringType!.Assembly, memberName, "returns");
}
public virtual async Task<string?> GetParameterSummaryAsync(MethodInfo method, string parameterName)
{
var memberName = GetMemberNameForMethod(method);
var doc = await LoadXmlDocumentationAsync(method.DeclaringType!.Assembly);
if (doc == null)
{
return null;
}
var memberNode = doc.XPathSelectElement($"//member[@name='{memberName}']");
var paramNode = memberNode?.XPathSelectElement($"param[@name='{parameterName}']");
return CleanXmlText(paramNode);
}
public virtual async Task<string?> GetSummaryAsync(PropertyInfo property)
{
var memberName = GetMemberNameForProperty(property);
return await GetDocumentationElementAsync(property.DeclaringType!.Assembly, memberName, "summary");
}
protected virtual async Task<string?> GetDocumentationElementAsync(Assembly assembly, string memberName, string elementName)
{
var doc = await LoadXmlDocumentationAsync(assembly);
if (doc == null)
{
return null;
}
var memberNode = doc.XPathSelectElement($"//member[@name='{memberName}']");
var element = memberNode?.Element(elementName);
return CleanXmlText(element);
}
protected virtual Task<XDocument?> LoadXmlDocumentationAsync(Assembly assembly)
{
return _xmlDocCache.GetOrAdd(
assembly,
asm => new Lazy<Task<XDocument?>>(
() => LoadXmlDocumentationFromDiskAsync(asm),
LazyThreadSafetyMode.ExecutionAndPublication)
).Value;
}
protected virtual async Task<XDocument?> LoadXmlDocumentationFromDiskAsync(Assembly assembly)
{
if (string.IsNullOrEmpty(assembly.Location))
{
return null;
}
var xmlFilePath = Path.ChangeExtension(assembly.Location, ".xml");
if (!File.Exists(xmlFilePath))
{
return null;
}
try
{
await using var stream = new FileStream(xmlFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
return await XDocument.LoadAsync(stream, LoadOptions.None, CancellationToken.None);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to load XML documentation from {XmlFilePath}.", xmlFilePath);
return null;
}
}
private static string? CleanXmlText(XElement? element)
{
if (element == null)
{
return null;
}
// Convert to string first so we can process inline XML tags like <see cref="..."/>
var raw = element.ToString();
// Strip the outer element tags (e.g. <summary>...</summary>)
var start = raw.IndexOf('>') + 1;
var end = raw.LastIndexOf('<');
if (start >= end)
{
return null;
}
var inner = raw[start..end];
// Replace <see cref="T:Foo.Bar"/> with the short name "Bar"
// Replace <see langword="null"/> with "null"
// Replace <paramref name="x"/> and <typeparamref name="T"/> with the name
inner = XmlRefTagRegex.Replace(inner, m =>
{
var display = m.Groups["display"].Value;
// For cref values like "T:Foo.Bar.Baz", return only "Baz"
var dot = display.LastIndexOf('.');
return dot >= 0 ? display[(dot + 1)..] : display;
});
// Strip any remaining XML tags (e.g. <c>, <code>, <para>, <b>, etc.)
inner = XmlTagRegex.Replace(inner, string.Empty);
if (string.IsNullOrWhiteSpace(inner))
{
return null;
}
return WhitespaceRegex.Replace(inner.Trim(), " ");
}
private static string GetMemberNameForType(Type type)
{
return $"T:{GetTypeFullName(type)}";
}
private static string GetMemberNameForMethod(MethodInfo method)
{
var typeName = GetTypeFullName(method.DeclaringType!);
var parameters = method.GetParameters();
if (parameters.Length == 0)
{
return $"M:{typeName}.{method.Name}";
}
var paramTypes = string.Join(",",
parameters.Select(p => GetParameterTypeName(p.ParameterType)));
return $"M:{typeName}.{method.Name}({paramTypes})";
}
private static string GetMemberNameForProperty(PropertyInfo property)
{
var typeName = GetTypeFullName(property.DeclaringType!);
return $"P:{typeName}.{property.Name}";
}
private static string GetTypeFullName(Type type)
{
return type.FullName?.Replace('+', '.') ?? type.Name;
}
private static string GetParameterTypeName(Type type)
{
if (type.IsGenericType)
{
var genericDef = type.GetGenericTypeDefinition();
var defName = genericDef.FullName!;
defName = defName[..defName.IndexOf('`')];
var args = string.Join(",", type.GetGenericArguments().Select(GetParameterTypeName));
return $"{defName}{{{args}}}";
}
if (type.IsArray)
{
return GetParameterTypeName(type.GetElementType()!) + "[]";
}
if (type.IsByRef)
{
return GetParameterTypeName(type.GetElementType()!) + "@";
}
return type.FullName ?? type.Name;
}
}

224
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs

@ -1,7 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Threading.Tasks;
using Asp.Versioning; using Asp.Versioning;
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -12,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Volo.Abp.AspNetCore.Mvc.ApiExploring;
using Volo.Abp.AspNetCore.Mvc.Conventions; using Volo.Abp.AspNetCore.Mvc.Conventions;
using Volo.Abp.AspNetCore.Mvc.Utils; using Volo.Abp.AspNetCore.Mvc.Utils;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
@ -29,26 +33,30 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
private readonly IApiDescriptionGroupCollectionProvider _descriptionProvider; private readonly IApiDescriptionGroupCollectionProvider _descriptionProvider;
private readonly AbpAspNetCoreMvcOptions _abpAspNetCoreMvcOptions; private readonly AbpAspNetCoreMvcOptions _abpAspNetCoreMvcOptions;
private readonly AbpApiDescriptionModelOptions _modelOptions; private readonly AbpApiDescriptionModelOptions _modelOptions;
private readonly IXmlDocumentationProvider _xmlDocProvider;
public AspNetCoreApiDescriptionModelProvider( public AspNetCoreApiDescriptionModelProvider(
IOptions<AspNetCoreApiDescriptionModelProviderOptions> options, IOptions<AspNetCoreApiDescriptionModelProviderOptions> options,
IApiDescriptionGroupCollectionProvider descriptionProvider, IApiDescriptionGroupCollectionProvider descriptionProvider,
IOptions<AbpAspNetCoreMvcOptions> abpAspNetCoreMvcOptions, IOptions<AbpAspNetCoreMvcOptions> abpAspNetCoreMvcOptions,
IOptions<AbpApiDescriptionModelOptions> modelOptions) IOptions<AbpApiDescriptionModelOptions> modelOptions,
IXmlDocumentationProvider xmlDocProvider)
{ {
_options = options.Value; _options = options.Value;
_descriptionProvider = descriptionProvider; _descriptionProvider = descriptionProvider;
_abpAspNetCoreMvcOptions = abpAspNetCoreMvcOptions.Value; _abpAspNetCoreMvcOptions = abpAspNetCoreMvcOptions.Value;
_modelOptions = modelOptions.Value; _modelOptions = modelOptions.Value;
_xmlDocProvider = xmlDocProvider;
Logger = NullLogger<AspNetCoreApiDescriptionModelProvider>.Instance; Logger = NullLogger<AspNetCoreApiDescriptionModelProvider>.Instance;
} }
public ApplicationApiDescriptionModel CreateApiModel(ApplicationApiDescriptionModelRequestDto input) public virtual async Task<ApplicationApiDescriptionModel> CreateApiModelAsync(ApplicationApiDescriptionModelRequestDto input)
{ {
//TODO: Can cache the model? //TODO: Can cache the model?
var model = ApplicationApiDescriptionModel.Create(); var model = ApplicationApiDescriptionModel.Create();
var populatedControllers = new HashSet<ControllerApiDescriptionModel>();
foreach (var descriptionGroupItem in _descriptionProvider.ApiDescriptionGroups.Items) foreach (var descriptionGroupItem in _descriptionProvider.ApiDescriptionGroups.Items)
{ {
@ -59,7 +67,7 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
continue; continue;
} }
AddApiDescriptionToModel(apiDescription, model, input); await AddApiDescriptionToModelAsync(apiDescription, model, input, populatedControllers);
} }
} }
@ -80,10 +88,11 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
return model; return model;
} }
private void AddApiDescriptionToModel( private async Task AddApiDescriptionToModelAsync(
ApiDescription apiDescription, ApiDescription apiDescription,
ApplicationApiDescriptionModel applicationModel, ApplicationApiDescriptionModel applicationModel,
ApplicationApiDescriptionModelRequestDto input) ApplicationApiDescriptionModelRequestDto input,
HashSet<ControllerApiDescriptionModel> populatedControllers)
{ {
var controllerType = apiDescription var controllerType = apiDescription
.ActionDescriptor .ActionDescriptor
@ -139,10 +148,21 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
var implementFrom = controllerType.FullName; var implementFrom = controllerType.FullName;
var interfaceType = controllerType.GetInterfaces().FirstOrDefault(i => i.GetMethods().Any(x => x.ToString() == method.ToString())); foreach (var iface in controllerType.GetInterfaces())
if (interfaceType != null)
{ {
implementFrom = TypeHelper.GetFullNameHandlingNullableAndGenerics(interfaceType); try
{
var map = controllerType.GetInterfaceMap(iface);
if (Array.IndexOf(map.TargetMethods, method) >= 0)
{
implementFrom = TypeHelper.GetFullNameHandlingNullableAndGenerics(iface);
break;
}
}
catch (ArgumentException)
{
// GetInterfaceMap is not supported for some generic interface edge cases
}
} }
var actionModel = controllerModel.AddAction( var actionModel = controllerModel.AddAction(
@ -161,10 +181,22 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
if (input.IncludeTypes) if (input.IncludeTypes)
{ {
AddCustomTypesToModel(applicationModel, method); await AddCustomTypesToModelAsync(applicationModel, method, input.IncludeDescriptions);
} }
AddParameterDescriptionsToModel(actionModel, method, apiDescription); AddParameterDescriptionsToModel(actionModel, method, apiDescription);
if (input.IncludeDescriptions)
{
if (populatedControllers.Add(controllerModel))
{
await PopulateControllerDescriptionsAsync(controllerModel, controllerType);
}
var interfaceMethod = GetInterfaceMethod(method);
await PopulateActionDescriptionsAsync(actionModel, method, interfaceMethod);
await PopulateParameterDescriptionsAsync(actionModel, method, interfaceMethod);
}
} }
private static List<string> GetSupportedVersions(Type controllerType, MethodInfo method, private static List<string> GetSupportedVersions(Type controllerType, MethodInfo method,
@ -191,18 +223,18 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
return supportedVersions.Select(v => v.ToString()).Distinct().ToList(); return supportedVersions.Select(v => v.ToString()).Distinct().ToList();
} }
private void AddCustomTypesToModel(ApplicationApiDescriptionModel applicationModel, MethodInfo method) private async Task AddCustomTypesToModelAsync(ApplicationApiDescriptionModel applicationModel, MethodInfo method, bool includeDescriptions)
{ {
foreach (var parameterInfo in method.GetParameters()) foreach (var parameterInfo in method.GetParameters())
{ {
AddCustomTypesToModel(applicationModel, parameterInfo.ParameterType); await AddCustomTypesToModelAsync(applicationModel, parameterInfo.ParameterType, includeDescriptions);
} }
AddCustomTypesToModel(applicationModel, method.ReturnType); await AddCustomTypesToModelAsync(applicationModel, method.ReturnType, includeDescriptions);
} }
private static void AddCustomTypesToModel(ApplicationApiDescriptionModel applicationModel, private async Task AddCustomTypesToModelAsync(ApplicationApiDescriptionModel applicationModel,
Type? type) Type? type, bool includeDescriptions)
{ {
if (type == null) if (type == null)
{ {
@ -229,14 +261,14 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
if (TypeHelper.IsDictionary(type, out var keyType, out var valueType)) if (TypeHelper.IsDictionary(type, out var keyType, out var valueType))
{ {
AddCustomTypesToModel(applicationModel, keyType); await AddCustomTypesToModelAsync(applicationModel, keyType, includeDescriptions);
AddCustomTypesToModel(applicationModel, valueType); await AddCustomTypesToModelAsync(applicationModel, valueType, includeDescriptions);
return; return;
} }
if (TypeHelper.IsEnumerable(type, out var itemType)) if (TypeHelper.IsEnumerable(type, out var itemType))
{ {
AddCustomTypesToModel(applicationModel, itemType); await AddCustomTypesToModelAsync(applicationModel, itemType, includeDescriptions);
return; return;
} }
@ -244,11 +276,11 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
{ {
var genericTypeDefinition = type.GetGenericTypeDefinition(); var genericTypeDefinition = type.GetGenericTypeDefinition();
AddCustomTypesToModel(applicationModel, genericTypeDefinition); await AddCustomTypesToModelAsync(applicationModel, genericTypeDefinition, includeDescriptions);
foreach (var genericArgument in type.GetGenericArguments()) foreach (var genericArgument in type.GetGenericArguments())
{ {
AddCustomTypesToModel(applicationModel, genericArgument); await AddCustomTypesToModelAsync(applicationModel, genericArgument, includeDescriptions);
} }
return; return;
@ -262,11 +294,16 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
applicationModel.Types[typeName] = TypeApiDescriptionModel.Create(type); applicationModel.Types[typeName] = TypeApiDescriptionModel.Create(type);
AddCustomTypesToModel(applicationModel, type.BaseType); if (includeDescriptions)
{
await PopulateTypeDescriptionsAsync(applicationModel.Types[typeName], type);
}
await AddCustomTypesToModelAsync(applicationModel, type.BaseType, includeDescriptions);
foreach (var propertyInfo in type.GetProperties().Where(p => p.DeclaringType == type)) foreach (var propertyInfo in type.GetProperties().Where(p => p.DeclaringType == type))
{ {
AddCustomTypesToModel(applicationModel, propertyInfo.PropertyType); await AddCustomTypesToModelAsync(applicationModel, propertyInfo.PropertyType, includeDescriptions);
} }
} }
@ -414,4 +451,149 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
return null; return null;
} }
protected virtual async Task PopulateControllerDescriptionsAsync(ControllerApiDescriptionModel controllerModel, Type controllerType)
{
controllerModel.Summary = await _xmlDocProvider.GetSummaryAsync(controllerType);
controllerModel.Remarks = await _xmlDocProvider.GetRemarksAsync(controllerType);
if (controllerModel.Summary == null && controllerModel.Remarks == null)
{
foreach (var interfaceType in GetDirectInterfaces(controllerType).Where(i => !_modelOptions.IgnoredInterfaces.Contains(i)))
{
controllerModel.Summary = await _xmlDocProvider.GetSummaryAsync(interfaceType);
controllerModel.Remarks = await _xmlDocProvider.GetRemarksAsync(interfaceType);
if (controllerModel.Summary != null || controllerModel.Remarks != null)
{
break;
}
}
}
controllerModel.Description = controllerType.GetCustomAttribute<DescriptionAttribute>()?.Description;
controllerModel.DisplayName = controllerType.GetCustomAttribute<DisplayAttribute>()?.Name;
}
protected virtual async Task PopulateActionDescriptionsAsync(ActionApiDescriptionModel actionModel, MethodInfo method, MethodInfo? interfaceMethod)
{
actionModel.Summary = await _xmlDocProvider.GetSummaryAsync(method);
actionModel.Remarks = await _xmlDocProvider.GetRemarksAsync(method);
if (actionModel.Summary == null && actionModel.Remarks == null && interfaceMethod != null)
{
actionModel.Summary = await _xmlDocProvider.GetSummaryAsync(interfaceMethod);
actionModel.Remarks = await _xmlDocProvider.GetRemarksAsync(interfaceMethod);
}
actionModel.Description = method.GetCustomAttribute<DescriptionAttribute>()?.Description;
actionModel.DisplayName = method.GetCustomAttribute<DisplayAttribute>()?.Name;
actionModel.ReturnValue.Summary = await _xmlDocProvider.GetReturnsAsync(method);
if (actionModel.ReturnValue.Summary == null && interfaceMethod != null)
{
actionModel.ReturnValue.Summary = await _xmlDocProvider.GetReturnsAsync(interfaceMethod);
}
}
protected virtual async Task PopulateParameterDescriptionsAsync(ActionApiDescriptionModel actionModel, MethodInfo method, MethodInfo? interfaceMethod)
{
var methodParameters = method.GetParameters();
foreach (var param in actionModel.ParametersOnMethod)
{
var paramInfo = methodParameters.FirstOrDefault(p => p.Name == param.Name);
if (paramInfo == null)
{
continue;
}
param.Summary = await _xmlDocProvider.GetParameterSummaryAsync(method, param.Name);
if (param.Summary == null && interfaceMethod != null)
{
param.Summary = await _xmlDocProvider.GetParameterSummaryAsync(interfaceMethod, param.Name);
}
param.Description = paramInfo.GetCustomAttribute<DescriptionAttribute>()?.Description;
param.DisplayName = paramInfo.GetCustomAttribute<DisplayAttribute>()?.Name;
}
foreach (var param in actionModel.Parameters)
{
// Skip expanded properties from complex types - their descriptions
// should come from type-level documentation (PopulateTypeDescriptionsAsync)
if (!string.IsNullOrEmpty(param.DescriptorName) && param.Name != param.NameOnMethod)
{
continue;
}
param.Summary = await _xmlDocProvider.GetParameterSummaryAsync(method, param.NameOnMethod);
if (param.Summary == null && interfaceMethod != null)
{
param.Summary = await _xmlDocProvider.GetParameterSummaryAsync(interfaceMethod, param.NameOnMethod);
}
var paramInfo = methodParameters.FirstOrDefault(p => p.Name == param.NameOnMethod);
if (paramInfo != null)
{
param.Description = paramInfo.GetCustomAttribute<DescriptionAttribute>()?.Description;
param.DisplayName = paramInfo.GetCustomAttribute<DisplayAttribute>()?.Name;
}
}
}
private MethodInfo? GetInterfaceMethod(MethodInfo method)
{
var declaringType = method.DeclaringType;
if (declaringType == null || declaringType.IsInterface)
{
return null;
}
foreach (var interfaceType in GetDirectInterfaces(declaringType).Where(i => !_modelOptions.IgnoredInterfaces.Contains(i)))
{
var map = declaringType.GetInterfaceMap(interfaceType);
for (var i = 0; i < map.TargetMethods.Length; i++)
{
if (map.TargetMethods[i] == method)
{
return map.InterfaceMethods[i];
}
}
}
return null;
}
private static IEnumerable<Type> GetDirectInterfaces(Type type)
{
var allInterfaces = type.GetInterfaces();
var baseInterfaces = type.BaseType?.GetInterfaces() ?? Type.EmptyTypes;
return allInterfaces.Except(baseInterfaces);
}
protected virtual async Task PopulateTypeDescriptionsAsync(TypeApiDescriptionModel typeModel, Type type)
{
typeModel.Summary = await _xmlDocProvider.GetSummaryAsync(type);
typeModel.Remarks = await _xmlDocProvider.GetRemarksAsync(type);
typeModel.Description = type.GetCustomAttribute<DescriptionAttribute>()?.Description;
typeModel.DisplayName = type.GetCustomAttribute<DisplayAttribute>()?.Name;
if (typeModel.Properties == null)
{
return;
}
foreach (var propModel in typeModel.Properties)
{
var propInfo = type.GetProperty(propModel.Name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly);
if (propInfo == null)
{
continue;
}
propModel.Summary = await _xmlDocProvider.GetSummaryAsync(propInfo);
propModel.Description = propInfo.GetCustomAttribute<DescriptionAttribute>()?.Description;
propModel.DisplayName = propInfo.GetCustomAttribute<DisplayAttribute>()?.Name;
}
}
} }

7
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ProxyScripting/AbpServiceProxyScriptController.cs

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Volo.Abp.Auditing; using Volo.Abp.Auditing;
using Volo.Abp.Http; using Volo.Abp.Http;
@ -29,11 +30,11 @@ public class AbpServiceProxyScriptController : AbpController
[HttpGet] [HttpGet]
[Produces(MimeTypes.Application.Javascript, MimeTypes.Text.Plain)] [Produces(MimeTypes.Application.Javascript, MimeTypes.Text.Plain)]
public virtual ActionResult GetAll(ServiceProxyGenerationModel model) public virtual async Task<ActionResult> GetAll(ServiceProxyGenerationModel model)
{ {
model.Normalize(); model.Normalize();
var script = ProxyScriptManager.GetScript(model.CreateOptions()); var script = await ProxyScriptManager.GetScriptAsync(model.CreateOptions());
return Content( return Content(
Options.MinifyGeneratedScript == true Options.MinifyGeneratedScript == true

8
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs

@ -32,6 +32,14 @@ public class ActionApiDescriptionModel
public string? ImplementFrom { get; set; } public string? ImplementFrom { get; set; }
public string? Summary { get; set; }
public string? Remarks { get; set; }
public string? Description { get; set; }
public string? DisplayName { get; set; }
public ActionApiDescriptionModel() public ActionApiDescriptionModel()
{ {

2
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ApplicationApiDescriptionModelRequestDto.cs

@ -3,4 +3,6 @@
public class ApplicationApiDescriptionModelRequestDto public class ApplicationApiDescriptionModelRequestDto
{ {
public bool IncludeTypes { get; set; } public bool IncludeTypes { get; set; }
public bool IncludeDescriptions { get; set; }
} }

16
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ControllerApiDescriptionModel.cs

@ -19,6 +19,14 @@ public class ControllerApiDescriptionModel
public string Type { get; set; } = default!; public string Type { get; set; } = default!;
public string? Summary { get; set; }
public string? Remarks { get; set; }
public string? Description { get; set; }
public string? DisplayName { get; set; }
public List<ControllerInterfaceApiDescriptionModel> Interfaces { get; set; } = default!; public List<ControllerInterfaceApiDescriptionModel> Interfaces { get; set; } = default!;
public Dictionary<string, ActionApiDescriptionModel> Actions { get; set; } = default!; public Dictionary<string, ActionApiDescriptionModel> Actions { get; set; } = default!;
@ -66,6 +74,14 @@ public class ControllerApiDescriptionModel
Type = Type, Type = Type,
Interfaces = Interfaces, Interfaces = Interfaces,
ControllerName = ControllerName, ControllerName = ControllerName,
ControllerGroupName = ControllerGroupName,
IsRemoteService = IsRemoteService,
IsIntegrationService = IsIntegrationService,
ApiVersion = ApiVersion,
Summary = Summary,
Remarks = Remarks,
Description = Description,
DisplayName = DisplayName,
Actions = new Dictionary<string, ActionApiDescriptionModel>() Actions = new Dictionary<string, ActionApiDescriptionModel>()
}; };

4
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IApiDescriptionModelProvider.cs

@ -1,6 +1,8 @@
using System.Threading.Tasks;
namespace Volo.Abp.Http.Modeling; namespace Volo.Abp.Http.Modeling;
public interface IApiDescriptionModelProvider public interface IApiDescriptionModelProvider
{ {
ApplicationApiDescriptionModel CreateApiModel(ApplicationApiDescriptionModelRequestDto input); Task<ApplicationApiDescriptionModel> CreateApiModelAsync(ApplicationApiDescriptionModelRequestDto input);
} }

6
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/MethodParameterApiDescriptionModel.cs

@ -19,6 +19,12 @@ public class MethodParameterApiDescriptionModel
public object? DefaultValue { get; set; } public object? DefaultValue { get; set; }
public string? Summary { get; set; }
public string? Description { get; set; }
public string? DisplayName { get; set; }
public MethodParameterApiDescriptionModel() public MethodParameterApiDescriptionModel()
{ {

6
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ParameterApiDescriptionModel.cs

@ -26,6 +26,12 @@ public class ParameterApiDescriptionModel
public string? DescriptorName { get; set; } public string? DescriptorName { get; set; }
public string? Summary { get; set; }
public string? Description { get; set; }
public string? DisplayName { get; set; }
public ParameterApiDescriptionModel() public ParameterApiDescriptionModel()
{ {

6
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs

@ -32,6 +32,12 @@ public class PropertyApiDescriptionModel
public bool IsNullable { get; set; } public bool IsNullable { get; set; }
public string? Summary { get; set; }
public string? Description { get; set; }
public string? DisplayName { get; set; }
public static PropertyApiDescriptionModel Create(PropertyInfo propertyInfo) public static PropertyApiDescriptionModel Create(PropertyInfo propertyInfo)
{ {
var customAttributes = propertyInfo.GetCustomAttributes(true); var customAttributes = propertyInfo.GetCustomAttributes(true);

2
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs

@ -11,6 +11,8 @@ public class ReturnValueApiDescriptionModel
public string TypeSimple { get; set; } = default!; public string TypeSimple { get; set; } = default!;
public string? Summary { get; set; }
public ReturnValueApiDescriptionModel() public ReturnValueApiDescriptionModel()
{ {

8
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs

@ -20,6 +20,14 @@ public class TypeApiDescriptionModel
public PropertyApiDescriptionModel[]? Properties { get; set; } public PropertyApiDescriptionModel[]? Properties { get; set; }
public string? Summary { get; set; }
public string? Remarks { get; set; }
public string? Description { get; set; }
public string? DisplayName { get; set; }
public TypeApiDescriptionModel() public TypeApiDescriptionModel()
{ {

4
framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManager.cs

@ -1,6 +1,8 @@
using System.Threading.Tasks;
namespace Volo.Abp.Http.ProxyScripting; namespace Volo.Abp.Http.ProxyScripting;
public interface IProxyScriptManager public interface IProxyScriptManager
{ {
string GetScript(ProxyScriptingModel scriptingModel); Task<string> GetScriptAsync(ProxyScriptingModel scriptingModel);
} }

7
framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManagerCache.cs

@ -1,10 +1,9 @@
using System; using System;
using System.Threading.Tasks;
namespace Volo.Abp.Http.ProxyScripting; namespace Volo.Abp.Http.ProxyScripting;
public interface IProxyScriptManagerCache public interface IProxyScriptManagerCache
{ {
string GetOrAdd(string key, Func<string> factory); Task<string> GetOrAddAsync(string key, Func<Task<string>> factory);
void Set(string key, string value);
} }

13
framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManager.cs

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
@ -32,23 +33,21 @@ public class ProxyScriptManager : IProxyScriptManager, ITransientDependency
_options = options.Value; _options = options.Value;
} }
public string GetScript(ProxyScriptingModel scriptingModel) public async Task<string> GetScriptAsync(ProxyScriptingModel scriptingModel)
{ {
var cacheKey = CreateCacheKey(scriptingModel); var cacheKey = CreateCacheKey(scriptingModel);
if (scriptingModel.UseCache) if (scriptingModel.UseCache)
{ {
return _cache.GetOrAdd(cacheKey, () => CreateScript(scriptingModel)); return await _cache.GetOrAddAsync(cacheKey, () => CreateScriptAsync(scriptingModel));
} }
var script = CreateScript(scriptingModel); return await CreateScriptAsync(scriptingModel);
_cache.Set(cacheKey, script);
return script;
} }
private string CreateScript(ProxyScriptingModel scriptingModel) private async Task<string> CreateScriptAsync(ProxyScriptingModel scriptingModel)
{ {
var apiModel = _modelProvider.CreateApiModel(new ApplicationApiDescriptionModelRequestDto { IncludeTypes = false }); var apiModel = await _modelProvider.CreateApiModelAsync(new ApplicationApiDescriptionModelRequestDto { IncludeTypes = false });
if (scriptingModel.IsPartialRequest()) if (scriptingModel.IsPartialRequest())
{ {

31
framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManagerCache.cs

@ -1,26 +1,31 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Threading;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
namespace Volo.Abp.Http.ProxyScripting; namespace Volo.Abp.Http.ProxyScripting;
public class ProxyScriptManagerCache : IProxyScriptManagerCache, ISingletonDependency public class ProxyScriptManagerCache : IProxyScriptManagerCache, ISingletonDependency
{ {
private readonly ConcurrentDictionary<string, string> _cache; private readonly ConcurrentDictionary<string, string> _cache = new();
private readonly ConcurrentDictionary<string, Lazy<Task<string>>> _asyncCache = new();
public ProxyScriptManagerCache() public async Task<string> GetOrAddAsync(string key, Func<Task<string>> factory)
{ {
_cache = new ConcurrentDictionary<string, string>(); if (_cache.TryGetValue(key, out var cached))
} {
return cached;
}
public string GetOrAdd(string key, Func<string> factory) var result = await _asyncCache.GetOrAdd(
{ key,
return _cache.GetOrAdd(key, factory); _ => new Lazy<Task<string>>(factory, LazyThreadSafetyMode.ExecutionAndPublication)
} ).Value;
public void Set(string key, string value) _cache[key] = result;
{ _asyncCache.TryRemove(key, out _);
_cache[key] = value;
return result;
} }
} }

3
framework/src/Volo.Abp.OperationRateLimiting/FodyWeavers.xml

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait ContinueOnCapturedContext="false" />
</Weavers>

32
framework/src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\configureawait.props" />
<Import Project="..\..\..\common.props" />
<PropertyGroup>
<TargetFrameworks>netstandard2.0;netstandard2.1;net8.0;net9.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<WarningsAsErrors>Nullable</WarningsAsErrors>
<AssemblyName>Volo.Abp.OperationRateLimiting</AssemblyName>
<PackageId>Volo.Abp.OperationRateLimiting</PackageId>
<AssetTargetFallback>$(AssetTargetFallback);portable-net45+win8+wp8+wpa81;</AssetTargetFallback>
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
<RootNamespace />
</PropertyGroup>
<ItemGroup>
<None Remove="Volo\Abp\OperationRateLimiting\Localization\*.json" />
<EmbeddedResource Include="Volo\Abp\OperationRateLimiting\Localization\*.json" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Volo.Abp.AspNetCore.Abstractions\Volo.Abp.AspNetCore.Abstractions.csproj" />
<ProjectReference Include="..\Volo.Abp.Caching\Volo.Abp.Caching.csproj" />
<ProjectReference Include="..\Volo.Abp.DistributedLocking.Abstractions\Volo.Abp.DistributedLocking.Abstractions.csproj" />
<ProjectReference Include="..\Volo.Abp.Localization\Volo.Abp.Localization.csproj" />
<ProjectReference Include="..\Volo.Abp.Security\Volo.Abp.Security.csproj" />
</ItemGroup>
</Project>

14
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs

@ -0,0 +1,14 @@
namespace Volo.Abp.OperationRateLimiting;
public static class AbpOperationRateLimitingErrorCodes
{
/// <summary>
/// Default error code for rate limit exceeded (with a retry-after window).
/// </summary>
public const string ExceedLimit = "Volo.Abp.OperationRateLimiting:010001";
/// <summary>
/// Error code for ban policy (maxCount: 0) where requests are permanently denied.
/// </summary>
public const string ExceedLimitPermanently = "Volo.Abp.OperationRateLimiting:010002";
}

42
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingModule.cs

@ -0,0 +1,42 @@
using Volo.Abp.AspNetCore;
using Volo.Abp.Caching;
using Volo.Abp.DistributedLocking;
using Volo.Abp.Localization;
using Volo.Abp.Localization.ExceptionHandling;
using Volo.Abp.Modularity;
using Volo.Abp.Security;
using Volo.Abp.VirtualFileSystem;
namespace Volo.Abp.OperationRateLimiting;
[DependsOn(
typeof(AbpCachingModule),
typeof(AbpLocalizationModule),
typeof(AbpSecurityModule),
typeof(AbpAspNetCoreAbstractionsModule),
typeof(AbpDistributedLockingAbstractionsModule)
)]
public class AbpOperationRateLimitingModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpVirtualFileSystemOptions>(options =>
{
options.FileSets.AddEmbedded<AbpOperationRateLimitingModule>();
});
Configure<AbpLocalizationOptions>(options =>
{
options.Resources
.Add<AbpOperationRateLimitingResource>("en")
.AddVirtualJson("/Volo/Abp/OperationRateLimiting/Localization");
});
Configure<AbpExceptionLocalizationOptions>(options =>
{
options.MapCodeNamespace(
"Volo.Abp.OperationRateLimiting",
typeof(AbpOperationRateLimitingResource));
});
}
}

20
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace Volo.Abp.OperationRateLimiting;
public class AbpOperationRateLimitingOptions
{
public bool IsEnabled { get; set; } = true;
public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(5);
public Dictionary<string, OperationRateLimitingPolicy> Policies { get; } = new();
public void AddPolicy(string name, Action<OperationRateLimitingPolicyBuilder> configure)
{
var builder = new OperationRateLimitingPolicyBuilder(name);
configure(builder);
Policies[name] = builder.Build();
}
}

8
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingResource.cs

@ -0,0 +1,8 @@
using Volo.Abp.Localization;
namespace Volo.Abp.OperationRateLimiting;
[LocalizationResourceName("AbpOperationRateLimiting")]
public class AbpOperationRateLimitingResource
{
}

14
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/IOperationRateLimitingChecker.cs

@ -0,0 +1,14 @@
using System.Threading.Tasks;
namespace Volo.Abp.OperationRateLimiting;
public interface IOperationRateLimitingChecker
{
Task CheckAsync(string policyName, OperationRateLimitingContext? context = null);
Task<bool> IsAllowedAsync(string policyName, OperationRateLimitingContext? context = null);
Task<OperationRateLimitingResult> GetStatusAsync(string policyName, OperationRateLimitingContext? context = null);
Task ResetAsync(string policyName, OperationRateLimitingContext? context = null);
}

277
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs

@ -0,0 +1,277 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Volo.Abp.AspNetCore.WebClientInfo;
using Volo.Abp.DependencyInjection;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Users;
namespace Volo.Abp.OperationRateLimiting;
public class OperationRateLimitingChecker : IOperationRateLimitingChecker, ITransientDependency
{
protected AbpOperationRateLimitingOptions Options { get; }
protected IOperationRateLimitingPolicyProvider PolicyProvider { get; }
protected IServiceProvider ServiceProvider { get; }
protected IOperationRateLimitingStore Store { get; }
protected ICurrentUser CurrentUser { get; }
protected ICurrentTenant CurrentTenant { get; }
protected IWebClientInfoProvider WebClientInfoProvider { get; }
public OperationRateLimitingChecker(
IOptions<AbpOperationRateLimitingOptions> options,
IOperationRateLimitingPolicyProvider policyProvider,
IServiceProvider serviceProvider,
IOperationRateLimitingStore store,
ICurrentUser currentUser,
ICurrentTenant currentTenant,
IWebClientInfoProvider webClientInfoProvider)
{
Options = options.Value;
PolicyProvider = policyProvider;
ServiceProvider = serviceProvider;
Store = store;
CurrentUser = currentUser;
CurrentTenant = currentTenant;
WebClientInfoProvider = webClientInfoProvider;
}
public virtual async Task CheckAsync(string policyName, OperationRateLimitingContext? context = null)
{
if (!Options.IsEnabled)
{
return;
}
context = EnsureContext(context);
var policy = await PolicyProvider.GetAsync(policyName);
var rules = CreateRules(policy);
// Phase 1: Check ALL rules without incrementing to get complete status.
// Do not exit early: a later rule may have a larger RetryAfter that the caller needs to know about.
var checkResults = new List<OperationRateLimitingRuleResult>();
foreach (var rule in rules)
{
checkResults.Add(await rule.CheckAsync(context));
}
if (checkResults.Any(r => !r.IsAllowed))
{
// Throw without incrementing any counter; RetryAfter is the max across all blocking rules.
var aggregatedResult = AggregateResults(checkResults, policy);
ThrowRateLimitException(policy, aggregatedResult, context);
}
// Phase 2: All rules passed in Phase 1 - now increment counters.
// Guard against concurrent races where another request consumed the last quota
// between Phase 1 and Phase 2.
// Once any rule fails during increment, stop incrementing subsequent rules
// to minimize wasted quota. Remaining rules use read-only check instead.
var incrementResults = new List<OperationRateLimitingRuleResult>();
var phase2Failed = false;
foreach (var rule in rules)
{
if (phase2Failed)
{
incrementResults.Add(await rule.CheckAsync(context));
}
else
{
var result = await rule.AcquireAsync(context);
incrementResults.Add(result);
if (!result.IsAllowed)
{
phase2Failed = true;
}
}
}
if (phase2Failed)
{
var aggregatedResult = AggregateResults(incrementResults, policy);
ThrowRateLimitException(policy, aggregatedResult, context);
}
}
public virtual async Task<bool> IsAllowedAsync(string policyName, OperationRateLimitingContext? context = null)
{
if (!Options.IsEnabled)
{
return true;
}
context = EnsureContext(context);
var policy = await PolicyProvider.GetAsync(policyName);
var rules = CreateRules(policy);
foreach (var rule in rules)
{
var result = await rule.CheckAsync(context);
if (!result.IsAllowed)
{
return false;
}
}
return true;
}
public virtual async Task<OperationRateLimitingResult> GetStatusAsync(string policyName, OperationRateLimitingContext? context = null)
{
if (!Options.IsEnabled)
{
return new OperationRateLimitingResult
{
IsAllowed = true,
RemainingCount = int.MaxValue,
MaxCount = int.MaxValue,
CurrentCount = 0
};
}
context = EnsureContext(context);
var policy = await PolicyProvider.GetAsync(policyName);
var rules = CreateRules(policy);
var ruleResults = new List<OperationRateLimitingRuleResult>();
foreach (var rule in rules)
{
ruleResults.Add(await rule.CheckAsync(context));
}
return AggregateResults(ruleResults, policy);
}
public virtual async Task ResetAsync(string policyName, OperationRateLimitingContext? context = null)
{
if (!Options.IsEnabled)
{
return;
}
context = EnsureContext(context);
var policy = await PolicyProvider.GetAsync(policyName);
var rules = CreateRules(policy);
foreach (var rule in rules)
{
await rule.ResetAsync(context);
}
}
protected virtual OperationRateLimitingContext EnsureContext(OperationRateLimitingContext? context)
{
context ??= new OperationRateLimitingContext();
context.ServiceProvider = ServiceProvider;
return context;
}
protected virtual List<IOperationRateLimitingRule> CreateRules(OperationRateLimitingPolicy policy)
{
var rules = new List<IOperationRateLimitingRule>();
foreach (var ruleDefinition in policy.Rules)
{
rules.Add(new FixedWindowOperationRateLimitingRule(
policy.Name,
ruleDefinition,
Store,
CurrentUser,
CurrentTenant,
WebClientInfoProvider));
}
foreach (var customRuleType in policy.CustomRuleTypes)
{
rules.Add((IOperationRateLimitingRule)ServiceProvider.GetRequiredService(customRuleType));
}
return rules;
}
protected virtual OperationRateLimitingResult AggregateResults(
List<OperationRateLimitingRuleResult> ruleResults,
OperationRateLimitingPolicy policy)
{
var isAllowed = ruleResults.All(r => r.IsAllowed);
var mostRestrictive = ruleResults
.OrderBy(r => r.RemainingCount)
.ThenByDescending(r => r.RetryAfter ?? TimeSpan.Zero)
.First();
return new OperationRateLimitingResult
{
IsAllowed = isAllowed,
RemainingCount = mostRestrictive.RemainingCount,
MaxCount = mostRestrictive.MaxCount,
CurrentCount = mostRestrictive.CurrentCount,
RetryAfter = ruleResults.Any(r => !r.IsAllowed && r.RetryAfter.HasValue)
? ruleResults
.Where(r => !r.IsAllowed && r.RetryAfter.HasValue)
.Select(r => r.RetryAfter!.Value)
.Max()
: null,
WindowDuration = mostRestrictive.WindowDuration,
RuleResults = ruleResults
};
}
protected virtual void ThrowRateLimitException(
OperationRateLimitingPolicy policy,
OperationRateLimitingResult result,
OperationRateLimitingContext context)
{
var formatter = context.ServiceProvider.GetRequiredService<IOperationRateLimitingFormatter>();
var exception = new AbpOperationRateLimitingException(
policy.Name,
result,
policy.ErrorCode);
if (result.RetryAfter.HasValue)
{
exception.SetRetryAfterFormatted(formatter.Format(result.RetryAfter.Value));
}
if (result.WindowDuration > TimeSpan.Zero)
{
exception.SetWindowDescriptionFormatted(formatter.Format(result.WindowDuration));
}
if (result.RuleResults != null)
{
var ruleDetails = new List<Dictionary<string, object>>();
foreach (var ruleResult in result.RuleResults)
{
ruleDetails.Add(new Dictionary<string, object>
{
["RuleName"] = ruleResult.RuleName,
["IsAllowed"] = ruleResult.IsAllowed,
["MaxCount"] = ruleResult.MaxCount,
["RemainingCount"] = ruleResult.RemainingCount,
["CurrentCount"] = ruleResult.CurrentCount,
["WindowDurationSeconds"] = (int)ruleResult.WindowDuration.TotalSeconds,
["WindowDescription"] = ruleResult.WindowDuration > TimeSpan.Zero
? formatter.Format(ruleResult.WindowDuration)
: string.Empty,
["RetryAfterSeconds"] = (int)(ruleResult.RetryAfter?.TotalSeconds ?? 0),
["RetryAfter"] = ruleResult.RetryAfter.HasValue
? formatter.Format(ruleResult.RetryAfter.Value)
: string.Empty
});
}
exception.WithData("RuleDetails", ruleDetails);
}
foreach (var kvp in context.ExtraProperties)
{
exception.WithData(kvp.Key, kvp.Value!);
}
throw exception;
}
}

38
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingCheckerExtensions.cs

@ -0,0 +1,38 @@
using System.Threading.Tasks;
namespace Volo.Abp.OperationRateLimiting;
public static class OperationRateLimitingCheckerExtensions
{
public static Task CheckAsync(
this IOperationRateLimitingChecker checker,
string policyName,
string parameter)
{
return checker.CheckAsync(policyName, new OperationRateLimitingContext { Parameter = parameter });
}
public static Task<bool> IsAllowedAsync(
this IOperationRateLimitingChecker checker,
string policyName,
string parameter)
{
return checker.IsAllowedAsync(policyName, new OperationRateLimitingContext { Parameter = parameter });
}
public static Task<OperationRateLimitingResult> GetStatusAsync(
this IOperationRateLimitingChecker checker,
string policyName,
string parameter)
{
return checker.GetStatusAsync(policyName, new OperationRateLimitingContext { Parameter = parameter });
}
public static Task ResetAsync(
this IOperationRateLimitingChecker checker,
string policyName,
string parameter)
{
return checker.ResetAsync(policyName, new OperationRateLimitingContext { Parameter = parameter });
}
}

33
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs

@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.DependencyInjection;
namespace Volo.Abp.OperationRateLimiting;
public class OperationRateLimitingContext
{
/// <summary>
/// Optional parameter passed by the caller.
/// Used as the partition key by PartitionByParameter() (required),
/// and as a fallback by PartitionByEmail() and PartitionByPhoneNumber().
/// Can be email, phone number, user id, resource id, or any string.
/// </summary>
public string? Parameter { get; set; }
/// <summary>
/// Additional properties that can be read by custom <see cref="IOperationRateLimitingRule"/> implementations
/// and are forwarded to the exception's Data dictionary when the rate limit is exceeded.
/// </summary>
public Dictionary<string, object?> ExtraProperties { get; set; } = new();
/// <summary>
/// The service provider for resolving services.
/// Set automatically by the checker.
/// </summary>
public IServiceProvider ServiceProvider { get; set; } = default!;
public T GetRequiredService<T>() where T : notnull
=> ServiceProvider.GetRequiredService<T>();
public T? GetService<T>() => ServiceProvider.GetService<T>();
}

24
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingResult.cs

@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
namespace Volo.Abp.OperationRateLimiting;
public class OperationRateLimitingResult
{
public bool IsAllowed { get; set; }
public int RemainingCount { get; set; }
public int MaxCount { get; set; }
public int CurrentCount { get; set; }
public TimeSpan? RetryAfter { get; set; }
public TimeSpan WindowDuration { get; set; }
/// <summary>
/// Detailed results per rule (for composite policies).
/// </summary>
public List<OperationRateLimitingRuleResult>? RuleResults { get; set; }
}

20
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs

@ -0,0 +1,20 @@
using System;
namespace Volo.Abp.OperationRateLimiting;
public class OperationRateLimitingRuleResult
{
public string RuleName { get; set; } = default!;
public bool IsAllowed { get; set; }
public int CurrentCount { get; set; }
public int RemainingCount { get; set; }
public int MaxCount { get; set; }
public TimeSpan? RetryAfter { get; set; }
public TimeSpan WindowDuration { get; set; }
}

48
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs

@ -0,0 +1,48 @@
using System;
using Volo.Abp.ExceptionHandling;
namespace Volo.Abp.OperationRateLimiting;
public class AbpOperationRateLimitingException : BusinessException, IHasHttpStatusCode
{
public string PolicyName { get; }
public OperationRateLimitingResult Result { get; }
public int HttpStatusCode => 429;
public AbpOperationRateLimitingException(
string policyName,
OperationRateLimitingResult result,
string? errorCode = null)
: base(code: errorCode ?? ResolveDefaultErrorCode(result))
{
PolicyName = policyName;
Result = result;
WithData("PolicyName", policyName);
WithData("MaxCount", result.MaxCount);
WithData("CurrentCount", result.CurrentCount);
WithData("RemainingCount", result.RemainingCount);
WithData("RetryAfterSeconds", (int)(result.RetryAfter?.TotalSeconds ?? 0));
WithData("RetryAfterMinutes", (int)(result.RetryAfter?.TotalMinutes ?? 0));
WithData("WindowDurationSeconds", (int)result.WindowDuration.TotalSeconds);
}
internal void SetRetryAfterFormatted(string formattedRetryAfter)
{
WithData("RetryAfter", formattedRetryAfter);
}
internal void SetWindowDescriptionFormatted(string formattedWindowDescription)
{
WithData("WindowDescription", formattedWindowDescription);
}
private static string ResolveDefaultErrorCode(OperationRateLimitingResult result)
{
return result.RetryAfter.HasValue
? AbpOperationRateLimitingErrorCodes.ExceedLimit
: AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently;
}
}

68
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/DefaultOperationRateLimitingFormatter.cs

@ -0,0 +1,68 @@
using System;
using Microsoft.Extensions.Localization;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.OperationRateLimiting;
public class DefaultOperationRateLimitingFormatter
: IOperationRateLimitingFormatter, ITransientDependency
{
protected IStringLocalizer<AbpOperationRateLimitingResource> Localizer { get; }
public DefaultOperationRateLimitingFormatter(
IStringLocalizer<AbpOperationRateLimitingResource> localizer)
{
Localizer = localizer;
}
public virtual string Format(TimeSpan duration)
{
if (duration.TotalDays >= 365)
{
var years = (int)(duration.TotalDays / 365);
var remainingDays = (int)(duration.TotalDays % 365);
var months = remainingDays / 30;
return months > 0
? Localizer["RetryAfter:YearsAndMonths", years, months]
: Localizer["RetryAfter:Years", years];
}
if (duration.TotalDays >= 30)
{
var months = (int)(duration.TotalDays / 30);
var remainingDays = (int)(duration.TotalDays % 30);
return remainingDays > 0
? Localizer["RetryAfter:MonthsAndDays", months, remainingDays]
: Localizer["RetryAfter:Months", months];
}
if (duration.TotalDays >= 1)
{
var days = (int)duration.TotalDays;
var hours = duration.Hours;
return hours > 0
? Localizer["RetryAfter:DaysAndHours", days, hours]
: Localizer["RetryAfter:Days", days];
}
if (duration.TotalHours >= 1)
{
var hours = (int)duration.TotalHours;
var minutes = duration.Minutes;
return minutes > 0
? Localizer["RetryAfter:HoursAndMinutes", hours, minutes]
: Localizer["RetryAfter:Hours", hours];
}
if (duration.TotalMinutes >= 1)
{
var minutes = (int)duration.TotalMinutes;
var seconds = duration.Seconds;
return seconds > 0
? Localizer["RetryAfter:MinutesAndSeconds", minutes, seconds]
: Localizer["RetryAfter:Minutes", minutes];
}
return Localizer["RetryAfter:Seconds", (int)duration.TotalSeconds];
}
}

8
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/IOperationRateLimitingFormatter.cs

@ -0,0 +1,8 @@
using System;
namespace Volo.Abp.OperationRateLimiting;
public interface IOperationRateLimitingFormatter
{
string Format(TimeSpan duration);
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json

@ -0,0 +1,18 @@
{
"culture": "ar",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "تم تجاوز حد معدل العملية. يمكنك المحاولة مرة أخرى بعد {RetryAfter}.",
"RetryAfter:Years": "{0} سنة/سنوات",
"RetryAfter:YearsAndMonths": "{0} سنة/سنوات و {1} شهر/أشهر",
"RetryAfter:Months": "{0} شهر/أشهر",
"RetryAfter:MonthsAndDays": "{0} شهر/أشهر و {1} يوم/أيام",
"RetryAfter:Days": "{0} يوم/أيام",
"RetryAfter:DaysAndHours": "{0} يوم/أيام و {1} ساعة/ساعات",
"RetryAfter:Hours": "{0} ساعة/ساعات",
"RetryAfter:HoursAndMinutes": "{0} ساعة/ساعات و {1} دقيقة/دقائق",
"RetryAfter:Minutes": "{0} دقيقة/دقائق",
"RetryAfter:MinutesAndSeconds": "{0} دقيقة/دقائق و {1} ثانية/ثوان",
"RetryAfter:Seconds": "{0} ثانية/ثوان",
"Volo.Abp.OperationRateLimiting:010002": "تم تجاوز حد معدل العملية. هذا الطلب مرفوض بشكل دائم."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json

@ -0,0 +1,18 @@
{
"culture": "cs",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Překročen limit rychlosti operace. Můžete to zkusit znovu za {RetryAfter}.",
"RetryAfter:Years": "{0} rok(y/let)",
"RetryAfter:YearsAndMonths": "{0} rok(y/let) a {1} měsíc(e/ů)",
"RetryAfter:Months": "{0} měsíc(e/ů)",
"RetryAfter:MonthsAndDays": "{0} měsíc(e/ů) a {1} den/dny/dní",
"RetryAfter:Days": "{0} den/dny/dní",
"RetryAfter:DaysAndHours": "{0} den/dny/dní a {1} hodina/hodiny/hodin",
"RetryAfter:Hours": "{0} hodina/hodiny/hodin",
"RetryAfter:HoursAndMinutes": "{0} hodina/hodiny/hodin a {1} minuta/minuty/minut",
"RetryAfter:Minutes": "{0} minuta/minuty/minut",
"RetryAfter:MinutesAndSeconds": "{0} minuta/minuty/minut a {1} sekunda/sekundy/sekund",
"RetryAfter:Seconds": "{0} sekunda/sekundy/sekund",
"Volo.Abp.OperationRateLimiting:010002": "Byl překročen limit četnosti operace. Tento požadavek je trvale zamítnut."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json

@ -0,0 +1,18 @@
{
"culture": "de",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Betriebsratenlimit überschritten. Sie können es nach {RetryAfter} erneut versuchen.",
"RetryAfter:Years": "{0} Jahr(e)",
"RetryAfter:YearsAndMonths": "{0} Jahr(e) und {1} Monat(e)",
"RetryAfter:Months": "{0} Monat(e)",
"RetryAfter:MonthsAndDays": "{0} Monat(e) und {1} Tag(e)",
"RetryAfter:Days": "{0} Tag(e)",
"RetryAfter:DaysAndHours": "{0} Tag(e) und {1} Stunde(n)",
"RetryAfter:Hours": "{0} Stunde(n)",
"RetryAfter:HoursAndMinutes": "{0} Stunde(n) und {1} Minute(n)",
"RetryAfter:Minutes": "{0} Minute(n)",
"RetryAfter:MinutesAndSeconds": "{0} Minute(n) und {1} Sekunde(n)",
"RetryAfter:Seconds": "{0} Sekunde(n)",
"Volo.Abp.OperationRateLimiting:010002": "Das Vorgangshäufigkeitslimit wurde überschritten. Diese Anfrage wird dauerhaft abgelehnt."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json

@ -0,0 +1,18 @@
{
"culture": "el",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Υπέρβαση ορίου ρυθμού λειτουργίας. Μπορείτε να δοκιμάσετε ξανά μετά από {RetryAfter}.",
"RetryAfter:Years": "{0} έτος/η",
"RetryAfter:YearsAndMonths": "{0} έτος/η και {1} μήνας/ες",
"RetryAfter:Months": "{0} μήνας/ες",
"RetryAfter:MonthsAndDays": "{0} μήνας/ες και {1} ημέρα/ες",
"RetryAfter:Days": "{0} ημέρα/ες",
"RetryAfter:DaysAndHours": "{0} ημέρα/ες και {1} ώρα/ες",
"RetryAfter:Hours": "{0} ώρα/ες",
"RetryAfter:HoursAndMinutes": "{0} ώρα/ες και {1} λεπτό/ά",
"RetryAfter:Minutes": "{0} λεπτό/ά",
"RetryAfter:MinutesAndSeconds": "{0} λεπτό/ά και {1} δευτερόλεπτο/α",
"RetryAfter:Seconds": "{0} δευτερόλεπτο/α",
"Volo.Abp.OperationRateLimiting:010002": "Υπερβλήθηκε το όριο συχνότητας λειτουργίας. Αυτό το αίτημα απορρίπτεται μόνιμα."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json

@ -0,0 +1,18 @@
{
"culture": "en-GB",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Operation rate limit exceeded. You can try again after {RetryAfter}.",
"RetryAfter:Years": "{0} year(s)",
"RetryAfter:YearsAndMonths": "{0} year(s) and {1} month(s)",
"RetryAfter:Months": "{0} month(s)",
"RetryAfter:MonthsAndDays": "{0} month(s) and {1} day(s)",
"RetryAfter:Days": "{0} day(s)",
"RetryAfter:DaysAndHours": "{0} day(s) and {1} hour(s)",
"RetryAfter:Hours": "{0} hour(s)",
"RetryAfter:HoursAndMinutes": "{0} hour(s) and {1} minute(s)",
"RetryAfter:Minutes": "{0} minute(s)",
"RetryAfter:MinutesAndSeconds": "{0} minute(s) and {1} second(s)",
"RetryAfter:Seconds": "{0} second(s)",
"Volo.Abp.OperationRateLimiting:010002": "Operation rate limit exceeded. This request is permanently denied."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json

@ -0,0 +1,18 @@
{
"culture": "en",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Operation rate limit exceeded. You can try again after {RetryAfter}.",
"RetryAfter:Years": "{0} year(s)",
"RetryAfter:YearsAndMonths": "{0} year(s) and {1} month(s)",
"RetryAfter:Months": "{0} month(s)",
"RetryAfter:MonthsAndDays": "{0} month(s) and {1} day(s)",
"RetryAfter:Days": "{0} day(s)",
"RetryAfter:DaysAndHours": "{0} day(s) and {1} hour(s)",
"RetryAfter:Hours": "{0} hour(s)",
"RetryAfter:HoursAndMinutes": "{0} hour(s) and {1} minute(s)",
"RetryAfter:Minutes": "{0} minute(s)",
"RetryAfter:MinutesAndSeconds": "{0} minute(s) and {1} second(s)",
"RetryAfter:Seconds": "{0} second(s)",
"Volo.Abp.OperationRateLimiting:010002": "Operation rate limit exceeded. This request is permanently denied."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json

@ -0,0 +1,18 @@
{
"culture": "es",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Se ha excedido el límite de tasa de operación. Puede intentarlo de nuevo después de {RetryAfter}.",
"RetryAfter:Years": "{0} año(s)",
"RetryAfter:YearsAndMonths": "{0} año(s) y {1} mes(es)",
"RetryAfter:Months": "{0} mes(es)",
"RetryAfter:MonthsAndDays": "{0} mes(es) y {1} día(s)",
"RetryAfter:Days": "{0} día(s)",
"RetryAfter:DaysAndHours": "{0} día(s) y {1} hora(s)",
"RetryAfter:Hours": "{0} hora(s)",
"RetryAfter:HoursAndMinutes": "{0} hora(s) y {1} minuto(s)",
"RetryAfter:Minutes": "{0} minuto(s)",
"RetryAfter:MinutesAndSeconds": "{0} minuto(s) y {1} segundo(s)",
"RetryAfter:Seconds": "{0} segundo(s)",
"Volo.Abp.OperationRateLimiting:010002": "Se superó el límite de frecuencia de operación. Esta solicitud está permanentemente denegada."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json

@ -0,0 +1,18 @@
{
"culture": "fa",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "محدودیت نرخ عملیات فراتر رفته است. می‌توانید بعد از {RetryAfter} دوباره تلاش کنید.",
"RetryAfter:Years": "{0} سال",
"RetryAfter:YearsAndMonths": "{0} سال و {1} ماه",
"RetryAfter:Months": "{0} ماه",
"RetryAfter:MonthsAndDays": "{0} ماه و {1} روز",
"RetryAfter:Days": "{0} روز",
"RetryAfter:DaysAndHours": "{0} روز و {1} ساعت",
"RetryAfter:Hours": "{0} ساعت",
"RetryAfter:HoursAndMinutes": "{0} ساعت و {1} دقیقه",
"RetryAfter:Minutes": "{0} دقیقه",
"RetryAfter:MinutesAndSeconds": "{0} دقیقه و {1} ثانیه",
"RetryAfter:Seconds": "{0} ثانیه",
"Volo.Abp.OperationRateLimiting:010002": "محدودیت نرخ عملیات از حد مجاز فراتر رفت. این درخواست به طور دائمی رد شده است."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json

@ -0,0 +1,18 @@
{
"culture": "fi",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Toiminnon nopeusraja ylitetty. Voit yrittää uudelleen {RetryAfter} kuluttua.",
"RetryAfter:Years": "{0} vuosi/vuotta",
"RetryAfter:YearsAndMonths": "{0} vuosi/vuotta ja {1} kuukausi/kuukautta",
"RetryAfter:Months": "{0} kuukausi/kuukautta",
"RetryAfter:MonthsAndDays": "{0} kuukausi/kuukautta ja {1} päivä/päivää",
"RetryAfter:Days": "{0} päivä/päivää",
"RetryAfter:DaysAndHours": "{0} päivä/päivää ja {1} tunti/tuntia",
"RetryAfter:Hours": "{0} tunti/tuntia",
"RetryAfter:HoursAndMinutes": "{0} tunti/tuntia ja {1} minuutti/minuuttia",
"RetryAfter:Minutes": "{0} minuutti/minuuttia",
"RetryAfter:MinutesAndSeconds": "{0} minuutti/minuuttia ja {1} sekunti/sekuntia",
"RetryAfter:Seconds": "{0} sekunti/sekuntia",
"Volo.Abp.OperationRateLimiting:010002": "Toiminnan nopeusraja ylitettiin. Tämä pyyntö on pysyvästi hylätty."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json

@ -0,0 +1,18 @@
{
"culture": "fr",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Limite de taux d'opération dépassée. Vous pouvez réessayer après {RetryAfter}.",
"RetryAfter:Years": "{0} an(s)",
"RetryAfter:YearsAndMonths": "{0} an(s) et {1} mois",
"RetryAfter:Months": "{0} mois",
"RetryAfter:MonthsAndDays": "{0} mois et {1} jour(s)",
"RetryAfter:Days": "{0} jour(s)",
"RetryAfter:DaysAndHours": "{0} jour(s) et {1} heure(s)",
"RetryAfter:Hours": "{0} heure(s)",
"RetryAfter:HoursAndMinutes": "{0} heure(s) et {1} minute(s)",
"RetryAfter:Minutes": "{0} minute(s)",
"RetryAfter:MinutesAndSeconds": "{0} minute(s) et {1} seconde(s)",
"RetryAfter:Seconds": "{0} seconde(s)",
"Volo.Abp.OperationRateLimiting:010002": "La limite de fréquence d'opération a été dépassée. Cette demande est définitivement refusée."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json

@ -0,0 +1,18 @@
{
"culture": "hi",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "ऑपरेशन दर सीमा पार हो गई। आप {RetryAfter} के बाद पुनः प्रयास कर सकते हैं।",
"RetryAfter:Years": "{0} वर्ष",
"RetryAfter:YearsAndMonths": "{0} वर्ष और {1} महीना/महीने",
"RetryAfter:Months": "{0} महीना/महीने",
"RetryAfter:MonthsAndDays": "{0} महीना/महीने और {1} दिन",
"RetryAfter:Days": "{0} दिन",
"RetryAfter:DaysAndHours": "{0} दिन और {1} घंटा/घंटे",
"RetryAfter:Hours": "{0} घंटा/घंटे",
"RetryAfter:HoursAndMinutes": "{0} घंटा/घंटे और {1} मिनट",
"RetryAfter:Minutes": "{0} मिनट",
"RetryAfter:MinutesAndSeconds": "{0} मिनट और {1} सेकंड",
"RetryAfter:Seconds": "{0} सेकंड",
"Volo.Abp.OperationRateLimiting:010002": "ऑपरेशन दर सीमा पार हो गई। यह अनुरोध स्थायी रूप से अस्वीकृत है।"
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json

@ -0,0 +1,18 @@
{
"culture": "hr",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Prekoračeno ograničenje brzine operacije. Možete pokušati ponovo nakon {RetryAfter}.",
"RetryAfter:Years": "{0} godina/e",
"RetryAfter:YearsAndMonths": "{0} godina/e i {1} mjesec/i",
"RetryAfter:Months": "{0} mjesec/i",
"RetryAfter:MonthsAndDays": "{0} mjesec/i i {1} dan/a",
"RetryAfter:Days": "{0} dan/a",
"RetryAfter:DaysAndHours": "{0} dan/a i {1} sat/i",
"RetryAfter:Hours": "{0} sat/i",
"RetryAfter:HoursAndMinutes": "{0} sat/i i {1} minuta/e",
"RetryAfter:Minutes": "{0} minuta/e",
"RetryAfter:MinutesAndSeconds": "{0} minuta/e i {1} sekunda/e",
"RetryAfter:Seconds": "{0} sekunda/e",
"Volo.Abp.OperationRateLimiting:010002": "Prekoračeno je ograničenje brzine operacije. Ovaj zahtjev je trajno odbijen."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json

@ -0,0 +1,18 @@
{
"culture": "hu",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "A műveleti sebességkorlát túllépve. Újra próbálkozhat {RetryAfter} múlva.",
"RetryAfter:Years": "{0} év",
"RetryAfter:YearsAndMonths": "{0} év és {1} hónap",
"RetryAfter:Months": "{0} hónap",
"RetryAfter:MonthsAndDays": "{0} hónap és {1} nap",
"RetryAfter:Days": "{0} nap",
"RetryAfter:DaysAndHours": "{0} nap és {1} óra",
"RetryAfter:Hours": "{0} óra",
"RetryAfter:HoursAndMinutes": "{0} óra és {1} perc",
"RetryAfter:Minutes": "{0} perc",
"RetryAfter:MinutesAndSeconds": "{0} perc és {1} másodperc",
"RetryAfter:Seconds": "{0} másodperc",
"Volo.Abp.OperationRateLimiting:010002": "A műveleti ráta korlátja túllépve. Ez a kérés véglegesen elutasítva."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json

@ -0,0 +1,18 @@
{
"culture": "is",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Aðgerðarhraðatakmörk náð. Þú getur reynt aftur eftir {RetryAfter}.",
"RetryAfter:Years": "{0} ár",
"RetryAfter:YearsAndMonths": "{0} ár og {1} mánuð(ir)",
"RetryAfter:Months": "{0} mánuð(ur/ir)",
"RetryAfter:MonthsAndDays": "{0} mánuð(ur/ir) og {1} dag(ur/ar)",
"RetryAfter:Days": "{0} dag(ur/ar)",
"RetryAfter:DaysAndHours": "{0} dag(ur/ar) og {1} klukkustund(ir)",
"RetryAfter:Hours": "{0} klukkustund(ir)",
"RetryAfter:HoursAndMinutes": "{0} klukkustund(ir) og {1} mínúta/úr",
"RetryAfter:Minutes": "{0} mínúta/úr",
"RetryAfter:MinutesAndSeconds": "{0} mínúta/úr og {1} sekúnda/úr",
"RetryAfter:Seconds": "{0} sekúnda/úr",
"Volo.Abp.OperationRateLimiting:010002": "Farið var yfir takmörk á rekstrartíðni. Þessari beiðni er varanlega hafnað."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json

@ -0,0 +1,18 @@
{
"culture": "it",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Limite di frequenza operazione superato. Puoi riprovare dopo {RetryAfter}.",
"RetryAfter:Years": "{0} anno/i",
"RetryAfter:YearsAndMonths": "{0} anno/i e {1} mese/i",
"RetryAfter:Months": "{0} mese/i",
"RetryAfter:MonthsAndDays": "{0} mese/i e {1} giorno/i",
"RetryAfter:Days": "{0} giorno/i",
"RetryAfter:DaysAndHours": "{0} giorno/i e {1} ora/e",
"RetryAfter:Hours": "{0} ora/e",
"RetryAfter:HoursAndMinutes": "{0} ora/e e {1} minuto/i",
"RetryAfter:Minutes": "{0} minuto/i",
"RetryAfter:MinutesAndSeconds": "{0} minuto/i e {1} secondo/i",
"RetryAfter:Seconds": "{0} secondo/i",
"Volo.Abp.OperationRateLimiting:010002": "Limite di frequenza operazione superato. Questa richiesta è permanentemente negata."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json

@ -0,0 +1,18 @@
{
"culture": "nl",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Bewerkingssnelheidslimiet overschreden. U kunt het opnieuw proberen na {RetryAfter}.",
"RetryAfter:Years": "{0} jaar",
"RetryAfter:YearsAndMonths": "{0} jaar en {1} maand(en)",
"RetryAfter:Months": "{0} maand(en)",
"RetryAfter:MonthsAndDays": "{0} maand(en) en {1} dag(en)",
"RetryAfter:Days": "{0} dag(en)",
"RetryAfter:DaysAndHours": "{0} dag(en) en {1} uur",
"RetryAfter:Hours": "{0} uur",
"RetryAfter:HoursAndMinutes": "{0} uur en {1} minuut/minuten",
"RetryAfter:Minutes": "{0} minuut/minuten",
"RetryAfter:MinutesAndSeconds": "{0} minuut/minuten en {1} seconde(n)",
"RetryAfter:Seconds": "{0} seconde(n)",
"Volo.Abp.OperationRateLimiting:010002": "Het bewerkingsfrequentielimiet is overschreden. Dit verzoek wordt permanent geweigerd."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json

@ -0,0 +1,18 @@
{
"culture": "pl-PL",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Przekroczono limit częstotliwości operacji. Możesz spróbować ponownie po {RetryAfter}.",
"RetryAfter:Years": "{0} rok/lat",
"RetryAfter:YearsAndMonths": "{0} rok/lat i {1} miesiąc/miesięcy",
"RetryAfter:Months": "{0} miesiąc/miesięcy",
"RetryAfter:MonthsAndDays": "{0} miesiąc/miesięcy i {1} dzień/dni",
"RetryAfter:Days": "{0} dzień/dni",
"RetryAfter:DaysAndHours": "{0} dzień/dni i {1} godzina/godzin",
"RetryAfter:Hours": "{0} godzina/godzin",
"RetryAfter:HoursAndMinutes": "{0} godzina/godzin i {1} minuta/minut",
"RetryAfter:Minutes": "{0} minuta/minut",
"RetryAfter:MinutesAndSeconds": "{0} minuta/minut i {1} sekunda/sekund",
"RetryAfter:Seconds": "{0} sekunda/sekund",
"Volo.Abp.OperationRateLimiting:010002": "Przekroczono limit częstotliwości operacji. To żądanie jest trwale odrzucone."
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save