diff --git a/common.props b/common.props index 6b892074f6..15fafa9120 100644 --- a/common.props +++ b/common.props @@ -1,8 +1,8 @@ latest - 10.2.0-rc.1 - 5.2.0-rc.1 + 10.3.0-preview + 5.3.0-preview $(NoWarn);CS1591;CS0436 https://abp.io/assets/abp_nupkg.png https://abp.io/ diff --git a/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/POST.md b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/POST.md new file mode 100644 index 0000000000..5855c9977a --- /dev/null +++ b/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(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()`. 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! diff --git a/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/ai-management-openai-anythingllm.png b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/ai-management-openai-anythingllm.png new file mode 100644 index 0000000000..b8b8fe109b Binary files /dev/null and b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/ai-management-openai-anythingllm.png differ diff --git a/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/chat-playground.png b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/chat-playground.png new file mode 100644 index 0000000000..e1ab32f314 Binary files /dev/null and b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/chat-playground.png differ diff --git a/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/cover-image.png b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/cover-image.png new file mode 100644 index 0000000000..f4bf16c1d3 Binary files /dev/null and b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/cover-image.png differ diff --git a/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/file-management-rba.png b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/file-management-rba.png new file mode 100644 index 0000000000..46e5506f17 Binary files /dev/null and b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/file-management-rba.png differ diff --git a/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/mcp-servers.png b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/mcp-servers.png new file mode 100644 index 0000000000..cbb93403d1 Binary files /dev/null and b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/mcp-servers.png differ diff --git a/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-embedder.png b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-embedder.png new file mode 100644 index 0000000000..378ba8f4e5 Binary files /dev/null and b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-embedder.png differ diff --git a/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-file-upload.png b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-file-upload.png new file mode 100644 index 0000000000..c4d4391a15 Binary files /dev/null and b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-file-upload.png differ diff --git a/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-vector-store.png b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-vector-store.png new file mode 100644 index 0000000000..83d38aeb2a Binary files /dev/null and b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/rag-vector-store.png differ diff --git a/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/studio-switch-to-preview.png b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/studio-switch-to-preview.png new file mode 100644 index 0000000000..62fd4d165e Binary files /dev/null and b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/studio-switch-to-preview.png differ diff --git a/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/test-connection.png b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/test-connection.png new file mode 100644 index 0000000000..3a92ccd9f2 Binary files /dev/null and b/docs/en/Blog-Posts/2026-02-25 v10_2_Preview/test-connection.png differ diff --git a/docs/en/docs-nav.json b/docs/en/docs-nav.json index 51b3c3e889..c3acbb6bb8 100644 --- a/docs/en/docs-nav.json +++ b/docs/en/docs-nav.json @@ -807,6 +807,10 @@ "text": "Object to Object Mapping", "path": "framework/infrastructure/object-to-object-mapping.md" }, + { + "text": "Operation Rate Limiting", + "path": "framework/infrastructure/operation-rate-limiting.md" + }, { "text": "Settings", "path": "framework/infrastructure/settings.md" diff --git a/docs/en/framework/infrastructure/operation-rate-limiting.md b/docs/en/framework/infrastructure/operation-rate-limiting.md new file mode 100644 index 0000000000..5208c2c959 --- /dev/null +++ b/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(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(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(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 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 IncrementAsync( + string key, TimeSpan duration, int maxCount) + { + // Your custom implementation (e.g., Redis Lua script for atomicity) + } + + public Task 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()`: + +````csharp +policy.AddRule(); +```` + +### 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) diff --git a/docs/en/low-code/custom-endpoints.md b/docs/en/low-code/custom-endpoints.md index b3af587707..ad0057df70 100644 --- a/docs/en/low-code/custom-endpoints.md +++ b/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", "method": "GET", "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", "method": "GET", "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 })));" } ``` diff --git a/docs/en/low-code/scripting-api.md b/docs/en/low-code/scripting-api.md index 5cd790b7aa..a08b1107ec 100644 --- a/docs/en/low-code/scripting-api.md +++ b/docs/en/low-code/scripting-api.md @@ -21,8 +21,7 @@ The `db` object is the main entry point for all data operations. ```javascript // Immutable pattern — each call creates a new builder -var entityTable = await db.query('Entity'); -var baseQuery = await entityTable.where(x => x.Active); +var baseQuery = db.query('Entity').where(x => x.Active); var cheap = baseQuery.where(x => x.Price < 100); // 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 ```javascript -var productTable = await db.query('LowCodeDemo.Products.Product'); -var products = await productTable +var products = await db.query('LowCodeDemo.Products.Product') .where(x => x.Price > 100) .orderBy(x => x.Price) .take(10) .toList(); -var resultTable = await db.query('LowCodeDemo.Products.Product'); -var result = await resultTable +var result = await db.query('LowCodeDemo.Products.Product') .where(x => x.Price > 100 && x.Price < 500) .where(x => x.StockCount > 0) .orderByDescending(x => x.Price) @@ -66,12 +63,9 @@ var result = await resultTable | `all(x => condition)` | Check if all records match | `Promise` | | `isEmpty()` | Check if no results | `Promise` | | `isSingle()` | Check if exactly one result | `Promise` | -| `first()` | Return first match, throws if empty | `Promise` | -| `firstOrDefault()` | Return first match or null | `Promise` | -| `last()` | Return last match, throws if empty | `Promise` | -| `lastOrDefault()` | Return last match or null | `Promise` | -| `single()` | Return single match, throws if empty/multiple | `Promise` | -| `singleOrDefault()` | Return single match or null (throws if multiple) | `Promise` | +| `first()` / `firstOrDefault()` | Return first match or null | `Promise` | +| `last()` / `lastOrDefault()` | Return last match or null | `Promise` | +| `single()` / `singleOrDefault()` | Return single match or null | `Promise` | | `elementAt(index)` | Return element at index | `Promise` | | `select(x => projection)` | Project to custom shape | `QueryBuilder` | | `join(entity, alias, condition)` | Inner join | `QueryBuilder` | @@ -98,18 +92,16 @@ var minPrice = 100; var config = { minStock: 10 }; var nested = { range: { min: 50, max: 200 } }; -var entityTable = await db.query('Entity'); -var result = await entityTable.where(x => x.Price > minPrice).toList(); -var result2 = await entityTable.where(x => x.StockCount > config.minStock).toList(); -var result3 = await entityTable.where(x => x.Price >= nested.range.min).toList(); +var result = await db.query('Entity').where(x => x.Price > minPrice).toList(); +var result2 = await db.query('Entity').where(x => x.StockCount > config.minStock).toList(); +var result3 = await db.query('Entity').where(x => x.Price >= nested.range.min).toList(); ``` ### Contains / IN Operator ```javascript var targetPrices = [50, 100, 200]; -var entityTable = await db.query('Entity'); -var products = await entityTable +var products = await db.query('Entity') .where(x => targetPrices.includes(x.Price)) .toList(); ``` @@ -117,8 +109,7 @@ var products = await entityTable ### Select Projection ```javascript -var productTable = await db.query('LowCodeDemo.Products.Product'); -var projected = await productTable +var projected = await db.query('LowCodeDemo.Products.Product') .where(x => x.Price > 0) .select(x => ({ ProductName: x.Name, ProductPrice: x.Price })) .toList(); @@ -129,8 +120,7 @@ var projected = await productTable ### Explicit Joins ```javascript -var orderLineTable = await db.query('LowCodeDemo.Orders.OrderLine'); -var orderLines = await orderLineTable +var orderLines = await db.query('LowCodeDemo.Orders.OrderLine') .join('LowCodeDemo.Products.Product', 'p', (ol, p) => ol.ProductId === p.Id) .take(10) .toList(); @@ -145,8 +135,7 @@ orderLines.forEach(line => { ### Left Join ```javascript -var orderTable = await db.query('LowCodeDemo.Orders.Order'); -var orders = await orderTable +var orders = await db.query('LowCodeDemo.Orders.Order') .leftJoin('LowCodeDemo.Products.Product', 'p', (o, p) => o.CustomerId === p.Id) .toList(); @@ -160,22 +149,18 @@ orders.forEach(order => { ### LINQ-Style Join ```javascript -var orderTable = await db.query('Order'); -await orderTable.join( - 'LowCodeDemo.Products.Product', - o => o.ProductId, - p => p.Id -); +db.query('Order') + .join('LowCodeDemo.Products.Product', + o => o.ProductId, + p => p.Id) ``` ### Join with Filtered Query ```javascript -var productTable = await db.query('Product'); -var expensiveProducts = await productTable.where(p => p.Price > 100); +var expensiveProducts = db.query('Product').where(p => p.Price > 100); -var orderLineTable = await db.query('OrderLine'); -var orders = await orderLineTable +var orders = await db.query('OrderLine') .join(expensiveProducts, ol => ol.ProductId, p => p.Id) @@ -194,9 +179,8 @@ Set operations execute at the database level using SQL: | `except(query)` | `EXCEPT` | Elements in first, not second | ```javascript -var productTable = await db.query('Product'); -var cheap = await productTable.where(x => x.Price <= 100); -var popular = await productTable.where(x => x.Rating > 4); +var cheap = db.query('Product').where(x => x.Price <= 100); +var popular = db.query('Product').where(x => x.Rating > 4); var bestDeals = await cheap.intersect(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` | ```javascript -var productTable = await db.query('Product'); -var totalValue = await productTable.sum(x => x.Price); -var avgPrice = await (await productTable.where(x => x.InStock)).average(x => x.Price); -var cheapest = await productTable.min(x => x.Price); +var totalValue = await db.query('Product').sum(x => x.Price); +var avgPrice = await db.query('Product').where(x => x.InStock).average(x => x.Price); +var cheapest = await db.query('Product').min(x => x.Price); ``` ### GroupBy with Select ```javascript -var productTable = await db.query('Product'); -var grouped = await productTable +var grouped = await db.query('Product') .groupBy(x => x.Category) .select(g => ({ Category: g.Key, @@ -255,8 +237,7 @@ var grouped = await productTable ### GroupBy with Items ```javascript -var productTable = await db.query('Product'); -var grouped = await productTable +var grouped = await db.query('Product') .groupBy(x => x.Category) .select(g => ({ Category: g.Key, @@ -277,12 +258,11 @@ var grouped = await productTable Math functions translate to SQL functions (ROUND, FLOOR, CEILING, ABS, etc.): ```javascript -var productTable = await db.query('Product'); -var products = await productTable +var products = await db.query('Product') .where(x => Math.round(x.Price) > 100) .toList(); -var result = await productTable +var result = await db.query('Product') .where(x => Math.abs(x.Balance) < 10 && Math.floor(x.Rating) >= 4) .toList(); ``` @@ -400,8 +380,7 @@ All values are parameterized: ```javascript var malicious = "'; DROP TABLE Products;--"; // Safely treated as a literal string — no injection -var entityTable = await db.query('Entity'); -var result = await (await entityTable.where(x => x.Name.includes(malicious))).count(); +var result = await db.query('Entity').where(x => x.Name.includes(malicious)).count(); ``` ### Blocked Features @@ -418,8 +397,7 @@ if (!context.commandArgs.getValue('Email').includes('@')) { // Try-catch for safe execution try { - var entityTable = await db.query('Entity'); - var products = await entityTable.where(x => x.Price > 0).toList(); + var products = await db.query('Entity').where(x => x.Price > 0).toList(); } catch (error) { context.log('Query failed: ' + error.message); } @@ -444,8 +422,7 @@ try { var productId = context.commandArgs.getValue('ProductId'); var quantity = context.commandArgs.getValue('Quantity'); -var productTable = await db.query('LowCodeDemo.Products.Product'); -var product = await productTable +var product = await db.query('LowCodeDemo.Products.Product') .where(x => x.Id === productId) .first(); @@ -458,10 +435,11 @@ context.commandArgs.setValue('TotalAmount', product.Price * quantity); ### Sales Dashboard (Custom Endpoint) ```javascript -var orderTable = await db.query('LowCodeDemo.Orders.Order'); -var totalOrders = await orderTable.count(); -var delivered = await (await orderTable.where(x => x.IsDelivered === true)).count(); -var revenue = await (await orderTable.where(x => x.IsDelivered === true)).sum(x => x.TotalAmount); +var totalOrders = await db.query('LowCodeDemo.Orders.Order').count(); +var delivered = await db.query('LowCodeDemo.Orders.Order') + .where(x => x.IsDelivered === true).count(); +var revenue = await db.query('LowCodeDemo.Orders.Order') + .where(x => x.IsDelivered === true).sum(x => x.TotalAmount); return ok({ orders: totalOrders, diff --git a/docs/en/package-version-changes.md b/docs/en/package-version-changes.md index 349f261a47..f71e53db71 100644 --- a/docs/en/package-version-changes.md +++ b/docs/en/package-version-changes.md @@ -1,3 +1,10 @@ +```json +//[doc-seo] +{ + "Description": "Explore the latest version changes for ABP Framework packages, including updates and improvements in dependencies for seamless development." +} +``` + # Package Version Changes ## 10.2.0-rc.1 diff --git a/docs/en/tutorials/modular-crm/images/abp-studio-add-new-standard-module-ui-dialog-blazor-webapp.png b/docs/en/tutorials/modular-crm/images/abp-studio-add-new-standard-module-ui-dialog-blazor-webapp.png new file mode 100644 index 0000000000..5941de9284 Binary files /dev/null and b/docs/en/tutorials/modular-crm/images/abp-studio-add-new-standard-module-ui-dialog-blazor-webapp.png differ diff --git a/docs/en/tutorials/modular-crm/images/abp-studio-add-package-reference-dialog-3-blazor-webapp.png b/docs/en/tutorials/modular-crm/images/abp-studio-add-package-reference-dialog-3-blazor-webapp.png new file mode 100644 index 0000000000..df98abdec4 Binary files /dev/null and b/docs/en/tutorials/modular-crm/images/abp-studio-add-package-reference-dialog-3-blazor-webapp.png differ diff --git a/docs/en/tutorials/modular-crm/images/abp-studio-catalog-module-expanded-in-solution-explorer-blazor-webapp.png b/docs/en/tutorials/modular-crm/images/abp-studio-catalog-module-expanded-in-solution-explorer-blazor-webapp.png new file mode 100644 index 0000000000..69834ec643 Binary files /dev/null and b/docs/en/tutorials/modular-crm/images/abp-studio-catalog-module-expanded-in-solution-explorer-blazor-webapp.png differ diff --git a/docs/en/tutorials/modular-crm/images/abp-studio-imports-and-dependencies-v2-blazor-webapp.png b/docs/en/tutorials/modular-crm/images/abp-studio-imports-and-dependencies-v2-blazor-webapp.png new file mode 100644 index 0000000000..e45e3248a5 Binary files /dev/null and b/docs/en/tutorials/modular-crm/images/abp-studio-imports-and-dependencies-v2-blazor-webapp.png differ diff --git a/docs/en/tutorials/modular-crm/images/abp-studio-install-module-dialog-blazor-webapp.png b/docs/en/tutorials/modular-crm/images/abp-studio-install-module-dialog-blazor-webapp.png new file mode 100644 index 0000000000..5aaabf4ef9 Binary files /dev/null and b/docs/en/tutorials/modular-crm/images/abp-studio-install-module-dialog-blazor-webapp.png differ diff --git a/docs/en/tutorials/modular-crm/images/abp-studio-install-module-dialog-for-ordering-v2-blazor-webapp.png b/docs/en/tutorials/modular-crm/images/abp-studio-install-module-dialog-for-ordering-v2-blazor-webapp.png new file mode 100644 index 0000000000..07dc46fc76 Binary files /dev/null and b/docs/en/tutorials/modular-crm/images/abp-studio-install-module-dialog-for-ordering-v2-blazor-webapp.png differ diff --git a/docs/en/tutorials/modular-crm/images/abp-studio-modular-crm-with-standard-module-blazor-webapp.png b/docs/en/tutorials/modular-crm/images/abp-studio-modular-crm-with-standard-module-blazor-webapp.png new file mode 100644 index 0000000000..172d06f4d0 Binary files /dev/null and b/docs/en/tutorials/modular-crm/images/abp-studio-modular-crm-with-standard-module-blazor-webapp.png differ diff --git a/docs/en/tutorials/modular-crm/images/abp-studio-module-installation-dialog-for-catalog-blazor-webapp.png b/docs/en/tutorials/modular-crm/images/abp-studio-module-installation-dialog-for-catalog-blazor-webapp.png new file mode 100644 index 0000000000..3dd079b399 Binary files /dev/null and b/docs/en/tutorials/modular-crm/images/abp-studio-module-installation-dialog-for-catalog-blazor-webapp.png differ diff --git a/docs/en/tutorials/modular-crm/images/catalog-module-vs-code-blazor-webapp.png b/docs/en/tutorials/modular-crm/images/catalog-module-vs-code-blazor-webapp.png new file mode 100644 index 0000000000..0ad75a81bf Binary files /dev/null and b/docs/en/tutorials/modular-crm/images/catalog-module-vs-code-blazor-webapp.png differ diff --git a/docs/en/tutorials/modular-crm/images/vscode-catalog-index-razor-blazor-webapp.png b/docs/en/tutorials/modular-crm/images/vscode-catalog-index-razor-blazor-webapp.png new file mode 100644 index 0000000000..674b575527 Binary files /dev/null and b/docs/en/tutorials/modular-crm/images/vscode-catalog-index-razor-blazor-webapp.png differ diff --git a/docs/en/tutorials/modular-crm/index.md b/docs/en/tutorials/modular-crm/index.md index 5cd7baaecf..b56d059d39 100644 --- a/docs/en/tutorials/modular-crm/index.md +++ b/docs/en/tutorials/modular-crm/index.md @@ -14,6 +14,13 @@ } ``` +````json +//[doc-params] +{ + "UI": ["MVC","BlazorWebApp"] +} +```` + ````json //[doc-nav] { diff --git a/docs/en/tutorials/modular-crm/part-01.md b/docs/en/tutorials/modular-crm/part-01.md index d3b5d64380..00d42fad3c 100644 --- a/docs/en/tutorials/modular-crm/part-01.md +++ b/docs/en/tutorials/modular-crm/part-01.md @@ -14,6 +14,13 @@ } ``` +````json +//[doc-params] +{ + "UI": ["MVC","BlazorWebApp"] +} +```` + ````json //[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: * **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 {{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 UI == "MVC"}} ![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.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.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 diff --git a/docs/en/tutorials/modular-crm/part-02.md b/docs/en/tutorials/modular-crm/part-02.md index 76c23d5e2e..ef38a09227 100644 --- a/docs/en/tutorials/modular-crm/part-02.md +++ b/docs/en/tutorials/modular-crm/part-02.md @@ -14,6 +14,13 @@ } ``` +````json +//[doc-params] +{ + "UI": ["MVC","BlazorWebApp"] +} +```` + ````json //[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 -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.** @@ -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: +{{if UI == "MVC"}} ![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 diff --git a/docs/en/tutorials/modular-crm/part-03.md b/docs/en/tutorials/modular-crm/part-03.md index 43457a9cca..52e5098e60 100644 --- a/docs/en/tutorials/modular-crm/part-03.md +++ b/docs/en/tutorials/modular-crm/part-03.md @@ -14,6 +14,13 @@ } ``` +````json +//[doc-params] +{ + "UI": ["MVC","BlazorWebApp"] +} +```` + ````json //[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: +{{if UI == "MVC"}} ![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: @@ -351,6 +362,8 @@ public partial class ProductToProductDtoMapper : MapperBase ### 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; > > 1. We will use these HTTP API endpoints in development to create some example data. @@ -358,6 +371,8 @@ public partial class ProductToProductDtoMapper : MapperBase > > 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: * 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. +{{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 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 +{{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: ![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 ```` -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 + +

Products

+ + + + + @foreach (var product in Products) + { + + @product.Name (stock: @product.StockCount) + + } + + + + +@code { + private List 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) diff --git a/docs/en/tutorials/modular-crm/part-04.md b/docs/en/tutorials/modular-crm/part-04.md index 26e312f05a..db119eed31 100644 --- a/docs/en/tutorials/modular-crm/part-04.md +++ b/docs/en/tutorials/modular-crm/part-04.md @@ -14,6 +14,13 @@ } ``` +````json +//[doc-params] +{ + "UI": ["MVC","BlazorWebApp"] +} +```` + ````json //[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. {{if UI == "MVC"}} - ![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"}} @@ -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: +{{if UI == "MVC"}} ![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 @@ -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: +{{if UI == "MVC"}} ![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"}} diff --git a/docs/en/tutorials/modular-crm/part-05.md b/docs/en/tutorials/modular-crm/part-05.md index ffce44fda2..c5582ec530 100644 --- a/docs/en/tutorials/modular-crm/part-05.md +++ b/docs/en/tutorials/modular-crm/part-05.md @@ -14,6 +14,13 @@ } ``` +````json +//[doc-params] +{ + "UI": ["MVC","BlazorWebApp"] +} +```` + ````json //[doc-nav] { @@ -364,6 +371,35 @@ Configure(options => 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 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 +{{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: ````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. +{{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 + +

Orders

+ + + + + @foreach (var order in Orders) + { + + Customer: @order.CustomerName
+ Product: @order.ProductId
+ State: @order.State +
+ } +
+
+
+ +@code { + private List 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 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: diff --git a/docs/en/tutorials/modular-crm/part-06.md b/docs/en/tutorials/modular-crm/part-06.md index 40647a9ec8..7eb7c5faba 100644 --- a/docs/en/tutorials/modular-crm/part-06.md +++ b/docs/en/tutorials/modular-crm/part-06.md @@ -14,6 +14,13 @@ } ``` +````json +//[doc-params] +{ + "UI": ["MVC","BlazorWebApp"] +} +```` + ````json //[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: +{{if UI == "MVC"}} ![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. @@ -258,7 +269,7 @@ Let's see what we've changed: {{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 @page @@ -282,6 +293,46 @@ Open the `Index.cshtml` file, and change the `@order.ProductId` part by `@order. ```` +{{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 + +

Orders

+ + + + + @foreach (var order in Orders) + { + + Customer: @order.CustomerName
+ Product: @order.ProductName
+ State: @order.State +
+ } +
+
+
+ +@code { + private List 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: ![abp-studio-browser-list-of-orders-with-product-name](images/abp-studio-browser-list-of-orders-with-product-name.png) diff --git a/docs/en/tutorials/modular-crm/part-07.md b/docs/en/tutorials/modular-crm/part-07.md index 4cfd272a18..2967b94dfa 100644 --- a/docs/en/tutorials/modular-crm/part-07.md +++ b/docs/en/tutorials/modular-crm/part-07.md @@ -14,6 +14,13 @@ } ``` +````json +//[doc-params] +{ + "UI": ["MVC","BlazorWebApp"] +} +```` + ````json //[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: +{{if UI == "MVC"}} ![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. 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) +{{else if UI == "BlazorWebApp"}} +![abp-studio-imports-and-dependencies](images/abp-studio-imports-and-dependencies-v2-blazor-webapp.png) +{{end}} ### Handling the `OrderPlacedEto` Event diff --git a/docs/en/tutorials/modular-crm/part-08.md b/docs/en/tutorials/modular-crm/part-08.md index 476edb7f6d..b7254b0c1f 100644 --- a/docs/en/tutorials/modular-crm/part-08.md +++ b/docs/en/tutorials/modular-crm/part-08.md @@ -14,6 +14,13 @@ # Integrating the Modules: Joining the Products and Orders Data +````json +//[doc-params] +{ + "UI": ["MVC","BlazorWebApp"] +} +```` + ````json //[doc-nav] { @@ -176,7 +183,7 @@ Now, you know the fundamental principles and mechanics of building sophisticated ## 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 diff --git a/framework/Volo.Abp.slnx b/framework/Volo.Abp.slnx index 1302600c09..1e36f1d212 100644 --- a/framework/Volo.Abp.slnx +++ b/framework/Volo.Abp.slnx @@ -169,6 +169,7 @@ + @@ -256,5 +257,6 @@ + diff --git a/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/AbpAspNetCoreAbstractionsModule.cs b/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/AbpAspNetCoreAbstractionsModule.cs index 6a15c5550f..603a578ef4 100644 --- a/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/AbpAspNetCoreAbstractionsModule.cs +++ b/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) { context.Services.AddSingleton(); - context.Services.AddSingleton();; + context.Services.AddSingleton(); } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/FodyWeavers.xml b/framework/src/Volo.Abp.OperationRateLimiting/FodyWeavers.xml new file mode 100644 index 0000000000..7e9f94ead6 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj b/framework/src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj new file mode 100644 index 0000000000..ffac7ef34e --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj @@ -0,0 +1,32 @@ + + + + + + + netstandard2.0;netstandard2.1;net8.0;net9.0;net10.0 + enable + Nullable + Volo.Abp.OperationRateLimiting + Volo.Abp.OperationRateLimiting + $(AssetTargetFallback);portable-net45+win8+wp8+wpa81; + false + false + false + + + + + + + + + + + + + + + + + diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs new file mode 100644 index 0000000000..ba2bb5c189 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs @@ -0,0 +1,14 @@ +namespace Volo.Abp.OperationRateLimiting; + +public static class AbpOperationRateLimitingErrorCodes +{ + /// + /// Default error code for rate limit exceeded (with a retry-after window). + /// + public const string ExceedLimit = "Volo.Abp.OperationRateLimiting:010001"; + + /// + /// Error code for ban policy (maxCount: 0) where requests are permanently denied. + /// + public const string ExceedLimitPermanently = "Volo.Abp.OperationRateLimiting:010002"; +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingModule.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingModule.cs new file mode 100644 index 0000000000..0462a285a5 --- /dev/null +++ b/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(options => + { + options.FileSets.AddEmbedded(); + }); + + Configure(options => + { + options.Resources + .Add("en") + .AddVirtualJson("/Volo/Abp/OperationRateLimiting/Localization"); + }); + + Configure(options => + { + options.MapCodeNamespace( + "Volo.Abp.OperationRateLimiting", + typeof(AbpOperationRateLimitingResource)); + }); + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs new file mode 100644 index 0000000000..711f2b17d0 --- /dev/null +++ b/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 Policies { get; } = new(); + + public void AddPolicy(string name, Action configure) + { + var builder = new OperationRateLimitingPolicyBuilder(name); + configure(builder); + Policies[name] = builder.Build(); + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingResource.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingResource.cs new file mode 100644 index 0000000000..e4f93d97d0 --- /dev/null +++ b/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 +{ +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/IOperationRateLimitingChecker.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/IOperationRateLimitingChecker.cs new file mode 100644 index 0000000000..2220c241e8 --- /dev/null +++ b/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 IsAllowedAsync(string policyName, OperationRateLimitingContext? context = null); + + Task GetStatusAsync(string policyName, OperationRateLimitingContext? context = null); + + Task ResetAsync(string policyName, OperationRateLimitingContext? context = null); +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs new file mode 100644 index 0000000000..095fa6cbf6 --- /dev/null +++ b/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 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(); + 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(); + 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 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 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(); + + 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 CreateRules(OperationRateLimitingPolicy policy) + { + var rules = new List(); + + 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 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(); + + 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>(); + foreach (var ruleResult in result.RuleResults) + { + ruleDetails.Add(new Dictionary + { + ["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; + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingCheckerExtensions.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingCheckerExtensions.cs new file mode 100644 index 0000000000..df8d195aab --- /dev/null +++ b/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 IsAllowedAsync( + this IOperationRateLimitingChecker checker, + string policyName, + string parameter) + { + return checker.IsAllowedAsync(policyName, new OperationRateLimitingContext { Parameter = parameter }); + } + + public static Task 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 }); + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs new file mode 100644 index 0000000000..f75c06b05b --- /dev/null +++ b/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 +{ + /// + /// 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. + /// + public string? Parameter { get; set; } + + /// + /// Additional properties that can be read by custom implementations + /// and are forwarded to the exception's Data dictionary when the rate limit is exceeded. + /// + public Dictionary ExtraProperties { get; set; } = new(); + + /// + /// The service provider for resolving services. + /// Set automatically by the checker. + /// + public IServiceProvider ServiceProvider { get; set; } = default!; + + public T GetRequiredService() where T : notnull + => ServiceProvider.GetRequiredService(); + + public T? GetService() => ServiceProvider.GetService(); +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingResult.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingResult.cs new file mode 100644 index 0000000000..6659947099 --- /dev/null +++ b/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; } + + /// + /// Detailed results per rule (for composite policies). + /// + public List? RuleResults { get; set; } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs new file mode 100644 index 0000000000..d725b8f7f2 --- /dev/null +++ b/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; } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs new file mode 100644 index 0000000000..88cfb2c6ff --- /dev/null +++ b/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; + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/DefaultOperationRateLimitingFormatter.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/DefaultOperationRateLimitingFormatter.cs new file mode 100644 index 0000000000..e69dd7082b --- /dev/null +++ b/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 Localizer { get; } + + public DefaultOperationRateLimitingFormatter( + IStringLocalizer 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]; + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/IOperationRateLimitingFormatter.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/IOperationRateLimitingFormatter.cs new file mode 100644 index 0000000000..7e6370e215 --- /dev/null +++ b/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); +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json new file mode 100644 index 0000000000..3ca1c8f042 --- /dev/null +++ b/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": "تم تجاوز حد معدل العملية. هذا الطلب مرفوض بشكل دائم." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json new file mode 100644 index 0000000000..44cfb86437 --- /dev/null +++ b/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." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json new file mode 100644 index 0000000000..44286d68a8 --- /dev/null +++ b/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." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json new file mode 100644 index 0000000000..a688778eb5 --- /dev/null +++ b/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": "Υπερβλήθηκε το όριο συχνότητας λειτουργίας. Αυτό το αίτημα απορρίπτεται μόνιμα." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json new file mode 100644 index 0000000000..4eab97f134 --- /dev/null +++ b/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." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json new file mode 100644 index 0000000000..fc41cabc2f --- /dev/null +++ b/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." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json new file mode 100644 index 0000000000..ce13a9bceb --- /dev/null +++ b/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." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json new file mode 100644 index 0000000000..0f200b5472 --- /dev/null +++ b/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": "محدودیت نرخ عملیات از حد مجاز فراتر رفت. این درخواست به طور دائمی رد شده است." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json new file mode 100644 index 0000000000..bcb88bf9ed --- /dev/null +++ b/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." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json new file mode 100644 index 0000000000..dca23d7a80 --- /dev/null +++ b/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." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json new file mode 100644 index 0000000000..a84a1e44a8 --- /dev/null +++ b/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": "ऑपरेशन दर सीमा पार हो गई। यह अनुरोध स्थायी रूप से अस्वीकृत है।" + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json new file mode 100644 index 0000000000..c6597be78e --- /dev/null +++ b/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." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json new file mode 100644 index 0000000000..f72dfea397 --- /dev/null +++ b/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." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json new file mode 100644 index 0000000000..cce36e42cd --- /dev/null +++ b/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ð." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json new file mode 100644 index 0000000000..f602fd1a66 --- /dev/null +++ b/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." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json new file mode 100644 index 0000000000..bb21e7f313 --- /dev/null +++ b/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." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json new file mode 100644 index 0000000000..e4503f6aa7 --- /dev/null +++ b/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." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json new file mode 100644 index 0000000000..fb6f873805 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json @@ -0,0 +1,18 @@ +{ + "culture": "pt-BR", + "texts": { + "Volo.Abp.OperationRateLimiting:010001": "Limite de taxa de operação excedido. Você pode tentar novamente após {RetryAfter}.", + "RetryAfter:Years": "{0} ano(s)", + "RetryAfter:YearsAndMonths": "{0} ano(s) e {1} mês/meses", + "RetryAfter:Months": "{0} mês/meses", + "RetryAfter:MonthsAndDays": "{0} mês/meses e {1} dia(s)", + "RetryAfter:Days": "{0} dia(s)", + "RetryAfter:DaysAndHours": "{0} dia(s) e {1} hora(s)", + "RetryAfter:Hours": "{0} hora(s)", + "RetryAfter:HoursAndMinutes": "{0} hora(s) e {1} minuto(s)", + "RetryAfter:Minutes": "{0} minuto(s)", + "RetryAfter:MinutesAndSeconds": "{0} minuto(s) e {1} segundo(s)", + "RetryAfter:Seconds": "{0} segundo(s)", + "Volo.Abp.OperationRateLimiting:010002": "Limite de taxa de operação excedido. Esta solicitação está permanentemente negada." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json new file mode 100644 index 0000000000..178042fa85 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json @@ -0,0 +1,18 @@ +{ + "culture": "ro-RO", + "texts": { + "Volo.Abp.OperationRateLimiting:010001": "Limita ratei de operare a fost depășită. Puteți încerca din nou după {RetryAfter}.", + "RetryAfter:Years": "{0} an/ani", + "RetryAfter:YearsAndMonths": "{0} an/ani și {1} lună/luni", + "RetryAfter:Months": "{0} lună/luni", + "RetryAfter:MonthsAndDays": "{0} lună/luni și {1} zi/zile", + "RetryAfter:Days": "{0} zi/zile", + "RetryAfter:DaysAndHours": "{0} zi/zile și {1} oră/ore", + "RetryAfter:Hours": "{0} oră/ore", + "RetryAfter:HoursAndMinutes": "{0} oră/ore și {1} minut(e)", + "RetryAfter:Minutes": "{0} minut(e)", + "RetryAfter:MinutesAndSeconds": "{0} minut(e) și {1} secundă/secunde", + "RetryAfter:Seconds": "{0} secundă/secunde", + "Volo.Abp.OperationRateLimiting:010002": "Limita de rată a operației a fost depășită. Această solicitare este permanent refuzată." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json new file mode 100644 index 0000000000..5260a9c90c --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json @@ -0,0 +1,18 @@ +{ + "culture": "ru", + "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": "Превышен лимит частоты операций. Этот запрос постоянно отклонён." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json new file mode 100644 index 0000000000..73cdbb2b5d --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json @@ -0,0 +1,18 @@ +{ + "culture": "sk", + "texts": { + "Volo.Abp.OperationRateLimiting:010001": "Prekročený limit rýchlosti operácie. Môžete to skúsiť znova po {RetryAfter}.", + "RetryAfter:Years": "{0} rok/rokov", + "RetryAfter:YearsAndMonths": "{0} rok/rokov a {1} mesiac/mesiacov", + "RetryAfter:Months": "{0} mesiac/mesiacov", + "RetryAfter:MonthsAndDays": "{0} mesiac/mesiacov a {1} deň/dní", + "RetryAfter:Days": "{0} deň/dní", + "RetryAfter:DaysAndHours": "{0} deň/dní a {1} hodina/hodín", + "RetryAfter:Hours": "{0} hodina/hodín", + "RetryAfter:HoursAndMinutes": "{0} hodina/hodín a {1} minúta/minút", + "RetryAfter:Minutes": "{0} minúta/minút", + "RetryAfter:MinutesAndSeconds": "{0} minúta/minút a {1} sekunda/sekúnd", + "RetryAfter:Seconds": "{0} sekunda/sekúnd", + "Volo.Abp.OperationRateLimiting:010002": "Bol prekročený limit frekvencie operácie. Táto požiadavka je trvalo zamietnutá." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json new file mode 100644 index 0000000000..333f51efb5 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json @@ -0,0 +1,18 @@ +{ + "culture": "sl", + "texts": { + "Volo.Abp.OperationRateLimiting:010001": "Presežena omejitev hitrosti operacije. Poskusite lahko znova čez {RetryAfter}.", + "RetryAfter:Years": "{0} leto/let", + "RetryAfter:YearsAndMonths": "{0} leto/let in {1} mesec/mesecev", + "RetryAfter:Months": "{0} mesec/mesecev", + "RetryAfter:MonthsAndDays": "{0} mesec/mesecev in {1} dan/dni", + "RetryAfter:Days": "{0} dan/dni", + "RetryAfter:DaysAndHours": "{0} dan/dni in {1} ura/ur", + "RetryAfter:Hours": "{0} ura/ur", + "RetryAfter:HoursAndMinutes": "{0} ura/ur in {1} minuta/minut", + "RetryAfter:Minutes": "{0} minuta/minut", + "RetryAfter:MinutesAndSeconds": "{0} minuta/minut in {1} sekunda/sekund", + "RetryAfter:Seconds": "{0} sekunda/sekund", + "Volo.Abp.OperationRateLimiting:010002": "Prekoračena je omejitev hitrosti operacije. Ta zahteva je trajno zavrnjena." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json new file mode 100644 index 0000000000..ef5172867c --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json @@ -0,0 +1,18 @@ +{ + "culture": "sv", + "texts": { + "Volo.Abp.OperationRateLimiting:010001": "Hastighetsgränsen för operationen har överskridits. Du kan försöka igen efter {RetryAfter}.", + "RetryAfter:Years": "{0} år", + "RetryAfter:YearsAndMonths": "{0} år och {1} månad(er)", + "RetryAfter:Months": "{0} månad(er)", + "RetryAfter:MonthsAndDays": "{0} månad(er) och {1} dag(ar)", + "RetryAfter:Days": "{0} dag(ar)", + "RetryAfter:DaysAndHours": "{0} dag(ar) och {1} timme/timmar", + "RetryAfter:Hours": "{0} timme/timmar", + "RetryAfter:HoursAndMinutes": "{0} timme/timmar och {1} minut(er)", + "RetryAfter:Minutes": "{0} minut(er)", + "RetryAfter:MinutesAndSeconds": "{0} minut(er) och {1} sekund(er)", + "RetryAfter:Seconds": "{0} sekund(er)", + "Volo.Abp.OperationRateLimiting:010002": "Hastighetsgränsen för operationen har överskridits. Denna förfrågan är permanent nekad." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json new file mode 100644 index 0000000000..0480003f9c --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json @@ -0,0 +1,18 @@ +{ + "culture": "tr", + "texts": { + "Volo.Abp.OperationRateLimiting:010001": "İşlem hız sınırı aşıldı. {RetryAfter} sonra tekrar deneyebilirsiniz.", + "RetryAfter:Years": "{0} yıl", + "RetryAfter:YearsAndMonths": "{0} yıl ve {1} ay", + "RetryAfter:Months": "{0} ay", + "RetryAfter:MonthsAndDays": "{0} ay ve {1} gün", + "RetryAfter:Days": "{0} gün", + "RetryAfter:DaysAndHours": "{0} gün ve {1} saat", + "RetryAfter:Hours": "{0} saat", + "RetryAfter:HoursAndMinutes": "{0} saat ve {1} dakika", + "RetryAfter:Minutes": "{0} dakika", + "RetryAfter:MinutesAndSeconds": "{0} dakika ve {1} saniye", + "RetryAfter:Seconds": "{0} saniye", + "Volo.Abp.OperationRateLimiting:010002": "İşlem hızı sınırı aşıldı. Bu istek kalıcı olarak reddedildi." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json new file mode 100644 index 0000000000..53059f819c --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json @@ -0,0 +1,18 @@ +{ + "culture": "vi", + "texts": { + "Volo.Abp.OperationRateLimiting:010001": "Đã vượt quá giới hạn tốc độ thao tác. Bạn có thể thử lại sau {RetryAfter}.", + "RetryAfter:Years": "{0} năm", + "RetryAfter:YearsAndMonths": "{0} năm và {1} tháng", + "RetryAfter:Months": "{0} tháng", + "RetryAfter:MonthsAndDays": "{0} tháng và {1} ngày", + "RetryAfter:Days": "{0} ngày", + "RetryAfter:DaysAndHours": "{0} ngày và {1} giờ", + "RetryAfter:Hours": "{0} giờ", + "RetryAfter:HoursAndMinutes": "{0} giờ và {1} phút", + "RetryAfter:Minutes": "{0} phút", + "RetryAfter:MinutesAndSeconds": "{0} phút và {1} giây", + "RetryAfter:Seconds": "{0} giây", + "Volo.Abp.OperationRateLimiting:010002": "Vượt quá giới hạn tần suất thao tác. Yêu cầu này bị từ chối vĩnh viễn." + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json new file mode 100644 index 0000000000..632d0e438f --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json @@ -0,0 +1,18 @@ +{ + "culture": "zh-Hans", + "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": "操作频率超出限制。此请求已被永久拒绝。" + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/DefaultOperationRateLimitingPolicyProvider.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/DefaultOperationRateLimitingPolicyProvider.cs new file mode 100644 index 0000000000..305863381a --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/DefaultOperationRateLimitingPolicyProvider.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.OperationRateLimiting; + +public class DefaultOperationRateLimitingPolicyProvider : IOperationRateLimitingPolicyProvider, ITransientDependency +{ + protected AbpOperationRateLimitingOptions Options { get; } + + public DefaultOperationRateLimitingPolicyProvider(IOptions options) + { + Options = options.Value; + } + + public virtual Task GetAsync(string policyName) + { + if (!Options.Policies.TryGetValue(policyName, out var policy)) + { + throw new AbpException( + $"Operation rate limit policy '{policyName}' was not found. " + + $"Make sure to configure it using AbpOperationRateLimitingOptions.AddPolicy()."); + } + + return Task.FromResult(policy); + } + + public virtual Task> GetListAsync() + { + return Task.FromResult(Options.Policies.Values.ToList()); + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/IOperationRateLimitingPolicyProvider.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/IOperationRateLimitingPolicyProvider.cs new file mode 100644 index 0000000000..1f87137a68 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/IOperationRateLimitingPolicyProvider.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Volo.Abp.OperationRateLimiting; + +public interface IOperationRateLimitingPolicyProvider +{ + Task GetAsync(string policyName); + + Task> GetListAsync(); +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPartitionType.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPartitionType.cs new file mode 100644 index 0000000000..e330bd8e46 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPartitionType.cs @@ -0,0 +1,12 @@ +namespace Volo.Abp.OperationRateLimiting; + +public enum OperationRateLimitingPartitionType +{ + Parameter, + CurrentUser, + CurrentTenant, + ClientIp, + Email, + PhoneNumber, + Custom +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicy.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicy.cs new file mode 100644 index 0000000000..45634e5de1 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicy.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace Volo.Abp.OperationRateLimiting; + +public class OperationRateLimitingPolicy +{ + public string Name { get; set; } = default!; + + public string? ErrorCode { get; set; } + + public List Rules { get; set; } = new(); + + public List CustomRuleTypes { get; set; } = new(); +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs new file mode 100644 index 0000000000..72cb247614 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Volo.Abp.OperationRateLimiting; + +public class OperationRateLimitingPolicyBuilder +{ + private readonly string _name; + private string? _errorCode; + private readonly List _rules = new(); + private readonly List _customRuleTypes = new(); + + public OperationRateLimitingPolicyBuilder(string name) + { + _name = Check.NotNullOrWhiteSpace(name, nameof(name)); + } + + /// + /// Add a built-in rule. Multiple rules are AND-combined. + /// + public OperationRateLimitingPolicyBuilder AddRule( + Action configure) + { + var builder = new OperationRateLimitingRuleBuilder(this); + configure(builder); + if (!builder.IsCommitted) + { + _rules.Add(builder.Build()); + } + return this; + } + + /// + /// Add a custom rule type (resolved from DI). + /// + public OperationRateLimitingPolicyBuilder AddRule() + where TRule : class, IOperationRateLimitingRule + { + _customRuleTypes.Add(typeof(TRule)); + return this; + } + + /// + /// Shortcut: single-rule policy with fixed window. + /// Returns the rule builder for partition configuration. + /// + public OperationRateLimitingRuleBuilder WithFixedWindow( + TimeSpan duration, int maxCount) + { + var builder = new OperationRateLimitingRuleBuilder(this); + builder.WithFixedWindow(duration, maxCount); + return builder; + } + + /// + /// Set a custom ErrorCode for this policy's exception. + /// + public OperationRateLimitingPolicyBuilder WithErrorCode(string errorCode) + { + _errorCode = Check.NotNullOrWhiteSpace(errorCode, nameof(errorCode)); + return this; + } + + internal void AddRuleDefinition(OperationRateLimitingRuleDefinition definition) + { + _rules.Add(definition); + } + + internal OperationRateLimitingPolicy Build() + { + if (_rules.Count == 0 && _customRuleTypes.Count == 0) + { + throw new AbpException( + $"Operation rate limit policy '{_name}' has no rules. " + + "Call AddRule() or WithFixedWindow(...).PartitionBy*() to add at least one rule."); + } + + var duplicate = _rules + .Where(r => r.PartitionType != OperationRateLimitingPartitionType.Custom) + .GroupBy(r => (r.Duration, r.MaxCount, r.PartitionType, r.IsMultiTenant)) + .FirstOrDefault(g => g.Count() > 1); + + if (duplicate != null) + { + var (duration, maxCount, partitionType, isMultiTenant) = duplicate.Key; + throw new AbpException( + $"Operation rate limit policy '{_name}' has duplicate rules with the same " + + $"Duration ({duration}), MaxCount ({maxCount}), PartitionType ({partitionType}), " + + $"and IsMultiTenant ({isMultiTenant}). " + + "Each rule in a policy must have a unique combination of these properties."); + } + + return new OperationRateLimitingPolicy + { + Name = _name, + ErrorCode = _errorCode, + Rules = new List(_rules), + CustomRuleTypes = new List(_customRuleTypes) + }; + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs new file mode 100644 index 0000000000..b9f2eacf9a --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs @@ -0,0 +1,157 @@ +using System; +using System.Threading.Tasks; + +namespace Volo.Abp.OperationRateLimiting; + +public class OperationRateLimitingRuleBuilder +{ + private readonly OperationRateLimitingPolicyBuilder _policyBuilder; + private TimeSpan _duration; + private int _maxCount; + private OperationRateLimitingPartitionType? _partitionType; + private Func>? _customPartitionKeyResolver; + private bool _isMultiTenant; + + internal bool IsCommitted { get; private set; } + + internal OperationRateLimitingRuleBuilder(OperationRateLimitingPolicyBuilder policyBuilder) + { + _policyBuilder = policyBuilder; + } + + public OperationRateLimitingRuleBuilder WithFixedWindow( + TimeSpan duration, int maxCount) + { + _duration = duration; + _maxCount = maxCount; + return this; + } + + public OperationRateLimitingRuleBuilder WithMultiTenancy() + { + _isMultiTenant = true; + return this; + } + + /// + /// Use context.Parameter as partition key. + /// + public OperationRateLimitingPolicyBuilder PartitionByParameter() + { + _partitionType = OperationRateLimitingPartitionType.Parameter; + CommitToPolicyBuilder(); + return _policyBuilder; + } + + /// + /// Partition by the current authenticated user (ICurrentUser.Id). + /// Use PartitionByParameter() if you need to specify the user ID explicitly. + /// + public OperationRateLimitingPolicyBuilder PartitionByCurrentUser() + { + _partitionType = OperationRateLimitingPartitionType.CurrentUser; + CommitToPolicyBuilder(); + return _policyBuilder; + } + + /// + /// Partition by the current tenant (ICurrentTenant.Id). Uses "host" when no tenant is active. + /// + public OperationRateLimitingPolicyBuilder PartitionByCurrentTenant() + { + _partitionType = OperationRateLimitingPartitionType.CurrentTenant; + CommitToPolicyBuilder(); + return _policyBuilder; + } + + /// + /// Partition by the client IP address (IWebClientInfoProvider.ClientIpAddress). + /// Use PartitionByParameter() if you need to specify the IP explicitly. + /// + public OperationRateLimitingPolicyBuilder PartitionByClientIp() + { + _partitionType = OperationRateLimitingPartitionType.ClientIp; + CommitToPolicyBuilder(); + return _policyBuilder; + } + + /// + /// Partition by email address. + /// Resolves from context.Parameter, falls back to ICurrentUser.Email. + /// + public OperationRateLimitingPolicyBuilder PartitionByEmail() + { + _partitionType = OperationRateLimitingPartitionType.Email; + CommitToPolicyBuilder(); + return _policyBuilder; + } + + /// + /// Partition by phone number. + /// Resolves from context.Parameter, falls back to ICurrentUser.PhoneNumber. + /// + public OperationRateLimitingPolicyBuilder PartitionByPhoneNumber() + { + _partitionType = OperationRateLimitingPartitionType.PhoneNumber; + CommitToPolicyBuilder(); + return _policyBuilder; + } + + /// + /// Custom async partition key resolver from context. + /// + public OperationRateLimitingPolicyBuilder PartitionBy( + Func> keyResolver) + { + _partitionType = OperationRateLimitingPartitionType.Custom; + _customPartitionKeyResolver = Check.NotNull(keyResolver, nameof(keyResolver)); + CommitToPolicyBuilder(); + return _policyBuilder; + } + + protected virtual void CommitToPolicyBuilder() + { + _policyBuilder.AddRuleDefinition(Build()); + IsCommitted = true; + } + + internal OperationRateLimitingRuleDefinition Build() + { + if (_duration <= TimeSpan.Zero) + { + throw new AbpException( + "Operation rate limit rule requires a positive duration. " + + "Call WithFixedWindow(duration, maxCount) before building the rule."); + } + + if (_maxCount < 0) + { + throw new AbpException( + "Operation rate limit rule requires maxCount >= 0. " + + "Use maxCount: 0 to completely deny all requests (ban policy)."); + } + + if (!_partitionType.HasValue) + { + throw new AbpException( + "Operation rate limit rule requires a partition type. " + + "Call PartitionByParameter(), PartitionByCurrentUser(), PartitionByClientIp(), or another PartitionBy*() method."); + } + + if (_partitionType == OperationRateLimitingPartitionType.Custom && _customPartitionKeyResolver == null) + { + throw new AbpException( + "Custom partition type requires a key resolver. " + + "Call PartitionBy(keyResolver) instead of setting partition type directly."); + } + + return new OperationRateLimitingRuleDefinition + { + Duration = _duration, + MaxCount = _maxCount, + PartitionType = _partitionType.Value, + CustomPartitionKeyResolver = _customPartitionKeyResolver, + IsMultiTenant = _isMultiTenant + }; + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs new file mode 100644 index 0000000000..f8d1bcf9e7 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace Volo.Abp.OperationRateLimiting; + +public class OperationRateLimitingRuleDefinition +{ + public TimeSpan Duration { get; set; } + + public int MaxCount { get; set; } + + public OperationRateLimitingPartitionType PartitionType { get; set; } + + public Func>? CustomPartitionKeyResolver { get; set; } + + public bool IsMultiTenant { get; set; } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs new file mode 100644 index 0000000000..bd869e2c5b --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs @@ -0,0 +1,147 @@ +using System.Threading.Tasks; +using Volo.Abp.AspNetCore.WebClientInfo; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Users; + +namespace Volo.Abp.OperationRateLimiting; + +public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule +{ + private const string HostTenantKey = "host"; + + protected string PolicyName { get; } + protected OperationRateLimitingRuleDefinition Definition { get; } + protected IOperationRateLimitingStore Store { get; } + protected ICurrentUser CurrentUser { get; } + protected ICurrentTenant CurrentTenant { get; } + protected IWebClientInfoProvider WebClientInfoProvider { get; } + + public FixedWindowOperationRateLimitingRule( + string policyName, + OperationRateLimitingRuleDefinition definition, + IOperationRateLimitingStore store, + ICurrentUser currentUser, + ICurrentTenant currentTenant, + IWebClientInfoProvider webClientInfoProvider) + { + PolicyName = policyName; + Definition = definition; + Store = store; + CurrentUser = currentUser; + CurrentTenant = currentTenant; + WebClientInfoProvider = webClientInfoProvider; + } + + public virtual async Task AcquireAsync( + OperationRateLimitingContext context) + { + var partitionKey = await ResolvePartitionKeyAsync(context); + var storeKey = BuildStoreKey(partitionKey); + var storeResult = await Store.IncrementAsync(storeKey, Definition.Duration, Definition.MaxCount); + + return ToRuleResult(storeResult); + } + + public virtual async Task CheckAsync( + OperationRateLimitingContext context) + { + var partitionKey = await ResolvePartitionKeyAsync(context); + var storeKey = BuildStoreKey(partitionKey); + var storeResult = await Store.GetAsync(storeKey, Definition.Duration, Definition.MaxCount); + + return ToRuleResult(storeResult); + } + + public virtual async Task ResetAsync(OperationRateLimitingContext context) + { + var partitionKey = await ResolvePartitionKeyAsync(context); + var storeKey = BuildStoreKey(partitionKey); + await Store.ResetAsync(storeKey); + } + + protected virtual async Task ResolvePartitionKeyAsync(OperationRateLimitingContext context) + { + return Definition.PartitionType switch + { + OperationRateLimitingPartitionType.Parameter => + context.Parameter ?? throw new AbpException( + $"OperationRateLimitingContext.Parameter is required for policy '{PolicyName}' (PartitionByParameter)."), + + OperationRateLimitingPartitionType.CurrentUser => + CurrentUser.Id?.ToString() + ?? throw new AbpException( + $"Current user is not authenticated. Policy '{PolicyName}' requires PartitionByCurrentUser. " + + "Use PartitionByParameter() if you need to specify the user ID explicitly."), + + OperationRateLimitingPartitionType.CurrentTenant => + CurrentTenant.Id?.ToString() + ?? HostTenantKey, + + OperationRateLimitingPartitionType.ClientIp => + WebClientInfoProvider.ClientIpAddress + ?? throw new AbpException( + $"Client IP address could not be determined. Policy '{PolicyName}' requires PartitionByClientIp. " + + "Ensure IWebClientInfoProvider is properly configured or use PartitionByParameter() to pass the IP explicitly."), + + OperationRateLimitingPartitionType.Email => + context.Parameter + ?? CurrentUser.Email + ?? throw new AbpException( + $"Email is required for policy '{PolicyName}' (PartitionByEmail). Provide it via context.Parameter or ensure the user has an email."), + + OperationRateLimitingPartitionType.PhoneNumber => + context.Parameter + ?? CurrentUser.PhoneNumber + ?? throw new AbpException( + $"Phone number is required for policy '{PolicyName}' (PartitionByPhoneNumber). Provide it via context.Parameter or ensure the user has a phone number."), + + OperationRateLimitingPartitionType.Custom => + await ResolveCustomPartitionKeyAsync(context), + + _ => throw new AbpException($"Unknown partition type: {Definition.PartitionType}") + }; + } + + protected virtual async Task ResolveCustomPartitionKeyAsync(OperationRateLimitingContext context) + { + var key = await Definition.CustomPartitionKeyResolver!(context); + if (string.IsNullOrEmpty(key)) + { + throw new AbpException( + $"Custom partition key resolver returned null or empty for policy '{PolicyName}'. " + + "The resolver must return a non-empty string."); + } + return key; + } + + protected virtual string BuildStoreKey(string partitionKey) + { + // Stable rule descriptor based on content so reordering rules does not change the key. + // Changing Duration or MaxCount intentionally resets counters for that rule. + var ruleKey = $"{(long)Definition.Duration.TotalSeconds}_{Definition.MaxCount}_{(int)Definition.PartitionType}"; + + // Tenant isolation is opt-in via WithMultiTenancy() on the rule builder. + // When not set, the key is global (shared across all tenants). + if (!Definition.IsMultiTenant) + { + return $"orl:{PolicyName}:{ruleKey}:{partitionKey}"; + } + + var tenantId = CurrentTenant.Id.HasValue ? CurrentTenant.Id.Value.ToString() : HostTenantKey; + return $"orl:t:{tenantId}:{PolicyName}:{ruleKey}:{partitionKey}"; + } + + protected virtual OperationRateLimitingRuleResult ToRuleResult(OperationRateLimitingStoreResult storeResult) + { + return new OperationRateLimitingRuleResult + { + RuleName = $"{PolicyName}:Rule[{(long)Definition.Duration.TotalSeconds}s,{Definition.MaxCount},{Definition.PartitionType}]", + IsAllowed = storeResult.IsAllowed, + CurrentCount = storeResult.CurrentCount, + RemainingCount = storeResult.MaxCount - storeResult.CurrentCount, + MaxCount = storeResult.MaxCount, + RetryAfter = storeResult.RetryAfter, + WindowDuration = Definition.Duration + }; + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/IOperationRateLimitingRule.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/IOperationRateLimitingRule.cs new file mode 100644 index 0000000000..1bb42a1727 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/IOperationRateLimitingRule.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; + +namespace Volo.Abp.OperationRateLimiting; + +public interface IOperationRateLimitingRule +{ + Task AcquireAsync(OperationRateLimitingContext context); + + Task CheckAsync(OperationRateLimitingContext context); + + Task ResetAsync(OperationRateLimitingContext context); +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs new file mode 100644 index 0000000000..d9f13b41d1 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs @@ -0,0 +1,155 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Options; +using Volo.Abp.Caching; +using Volo.Abp.DependencyInjection; +using Volo.Abp.DistributedLocking; +using Volo.Abp.Timing; + +namespace Volo.Abp.OperationRateLimiting; + +public class DistributedCacheOperationRateLimitingStore : IOperationRateLimitingStore, ITransientDependency +{ + protected IDistributedCache Cache { get; } + protected IClock Clock { get; } + protected IAbpDistributedLock DistributedLock { get; } + protected AbpOperationRateLimitingOptions Options { get; } + + public DistributedCacheOperationRateLimitingStore( + IDistributedCache cache, + IClock clock, + IAbpDistributedLock distributedLock, + IOptions options) + { + Cache = cache; + Clock = clock; + DistributedLock = distributedLock; + Options = options.Value; + } + + public virtual async Task IncrementAsync( + string key, TimeSpan duration, int maxCount) + { + if (maxCount <= 0) + { + return new OperationRateLimitingStoreResult + { + IsAllowed = false, + CurrentCount = 0, + MaxCount = maxCount, + RetryAfter = null + }; + } + + await using (var handle = await DistributedLock.TryAcquireAsync( + $"OperationRateLimiting:{key}", Options.LockTimeout)) + { + if (handle == null) + { + throw new AbpException( + "Could not acquire distributed lock for operation rate limit. " + + "This is an infrastructure issue, not a rate limit violation."); + } + + var cacheItem = await Cache.GetAsync(key); + var now = new DateTimeOffset(Clock.Now.ToUniversalTime()); + + if (cacheItem == null || now >= cacheItem.WindowStart.Add(duration)) + { + cacheItem = new OperationRateLimitingCacheItem { Count = 1, WindowStart = now }; + await Cache.SetAsync(key, cacheItem, + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = duration + }); + + return new OperationRateLimitingStoreResult + { + IsAllowed = true, + CurrentCount = 1, + MaxCount = maxCount + }; + } + + if (cacheItem.Count >= maxCount) + { + var retryAfter = cacheItem.WindowStart.Add(duration) - now; + return new OperationRateLimitingStoreResult + { + IsAllowed = false, + CurrentCount = cacheItem.Count, + MaxCount = maxCount, + RetryAfter = retryAfter + }; + } + + cacheItem.Count++; + var expiration = cacheItem.WindowStart.Add(duration) - now; + await Cache.SetAsync(key, cacheItem, + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = expiration > TimeSpan.Zero ? expiration : duration + }); + + return new OperationRateLimitingStoreResult + { + IsAllowed = true, + CurrentCount = cacheItem.Count, + MaxCount = maxCount + }; + } + } + + public virtual async Task GetAsync( + string key, TimeSpan duration, int maxCount) + { + if (maxCount <= 0) + { + return new OperationRateLimitingStoreResult + { + IsAllowed = false, + CurrentCount = 0, + MaxCount = maxCount, + RetryAfter = null + }; + } + + var cacheItem = await Cache.GetAsync(key); + var now = new DateTimeOffset(Clock.Now.ToUniversalTime()); + + if (cacheItem == null || now >= cacheItem.WindowStart.Add(duration)) + { + return new OperationRateLimitingStoreResult + { + IsAllowed = true, + CurrentCount = 0, + MaxCount = maxCount + }; + } + + if (cacheItem.Count >= maxCount) + { + var retryAfter = cacheItem.WindowStart.Add(duration) - now; + return new OperationRateLimitingStoreResult + { + IsAllowed = false, + CurrentCount = cacheItem.Count, + MaxCount = maxCount, + RetryAfter = retryAfter + }; + } + + return new OperationRateLimitingStoreResult + { + IsAllowed = true, + CurrentCount = cacheItem.Count, + MaxCount = maxCount + }; + } + + public virtual async Task ResetAsync(string key) + { + await Cache.RemoveAsync(key); + } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/IOperationRateLimitingStore.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/IOperationRateLimitingStore.cs new file mode 100644 index 0000000000..049fa35b0c --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/IOperationRateLimitingStore.cs @@ -0,0 +1,13 @@ +using System; +using System.Threading.Tasks; + +namespace Volo.Abp.OperationRateLimiting; + +public interface IOperationRateLimitingStore +{ + Task IncrementAsync(string key, TimeSpan duration, int maxCount); + + Task GetAsync(string key, TimeSpan duration, int maxCount); + + Task ResetAsync(string key); +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingCacheItem.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingCacheItem.cs new file mode 100644 index 0000000000..2d92d8578e --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingCacheItem.cs @@ -0,0 +1,14 @@ +using System; +using Volo.Abp.Caching; +using Volo.Abp.MultiTenancy; + +namespace Volo.Abp.OperationRateLimiting; + +[CacheName("OperationRateLimiting")] +[IgnoreMultiTenancy] +public class OperationRateLimitingCacheItem +{ + public int Count { get; set; } + + public DateTimeOffset WindowStart { get; set; } +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingStoreResult.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingStoreResult.cs new file mode 100644 index 0000000000..caa2bd640d --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingStoreResult.cs @@ -0,0 +1,14 @@ +using System; + +namespace Volo.Abp.OperationRateLimiting; + +public class OperationRateLimitingStoreResult +{ + public bool IsAllowed { get; set; } + + public int CurrentCount { get; set; } + + public int MaxCount { get; set; } + + public TimeSpan? RetryAfter { get; set; } +} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo.Abp.OperationRateLimiting.Tests.csproj b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo.Abp.OperationRateLimiting.Tests.csproj new file mode 100644 index 0000000000..a9d2d9ee36 --- /dev/null +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo.Abp.OperationRateLimiting.Tests.csproj @@ -0,0 +1,18 @@ + + + + + + net10.0 + + + + + + + + + + + + diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingException_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingException_Tests.cs new file mode 100644 index 0000000000..9d88c5e57d --- /dev/null +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingException_Tests.cs @@ -0,0 +1,117 @@ +using System; +using Shouldly; +using Xunit; + +namespace Volo.Abp.OperationRateLimiting; + +public class AbpOperationRateLimitingException_Tests +{ + [Fact] + public void Should_Set_HttpStatusCode_To_429() + { + var result = new OperationRateLimitingResult + { + IsAllowed = false, + MaxCount = 3, + CurrentCount = 3, + RemainingCount = 0, + RetryAfter = TimeSpan.FromMinutes(15) + }; + + var exception = new AbpOperationRateLimitingException("TestPolicy", result); + + exception.HttpStatusCode.ShouldBe(429); + } + + [Fact] + public void Should_Use_ExceedLimit_Code_When_RetryAfter_Is_Set() + { + var result = new OperationRateLimitingResult + { + IsAllowed = false, + MaxCount = 3, + CurrentCount = 3, + RemainingCount = 0, + RetryAfter = TimeSpan.FromMinutes(5) + }; + + var exception = new AbpOperationRateLimitingException("TestPolicy", result); + + exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimit); + } + + [Fact] + public void Should_Use_ExceedLimitPermanently_Code_When_RetryAfter_Is_Null() + { + var result = new OperationRateLimitingResult + { + IsAllowed = false, + MaxCount = 0, + CurrentCount = 0, + RemainingCount = 0, + RetryAfter = null + }; + + var exception = new AbpOperationRateLimitingException("TestPolicy", result); + + exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently); + } + + [Fact] + public void Should_Set_Custom_ErrorCode() + { + var result = new OperationRateLimitingResult + { + IsAllowed = false, + MaxCount = 3, + CurrentCount = 3, + RemainingCount = 0 + }; + + var exception = new AbpOperationRateLimitingException("TestPolicy", result, "App:Custom:Error"); + + exception.Code.ShouldBe("App:Custom:Error"); + } + + [Fact] + public void Should_Include_Data_Properties() + { + var result = new OperationRateLimitingResult + { + IsAllowed = false, + MaxCount = 3, + CurrentCount = 3, + RemainingCount = 0, + RetryAfter = TimeSpan.FromMinutes(15), + WindowDuration = TimeSpan.FromHours(1) + }; + + var exception = new AbpOperationRateLimitingException("TestPolicy", result); + + exception.Data["PolicyName"].ShouldBe("TestPolicy"); + exception.Data["MaxCount"].ShouldBe(3); + exception.Data["CurrentCount"].ShouldBe(3); + exception.Data["RemainingCount"].ShouldBe(0); + exception.Data["RetryAfterSeconds"].ShouldBe(900); + exception.Data["RetryAfterMinutes"].ShouldBe(15); + exception.Data["WindowDurationSeconds"].ShouldBe(3600); + } + + [Fact] + public void Should_Store_PolicyName_And_Result() + { + var result = new OperationRateLimitingResult + { + IsAllowed = false, + MaxCount = 5, + CurrentCount = 5, + RemainingCount = 0, + RetryAfter = TimeSpan.FromHours(1) + }; + + var exception = new AbpOperationRateLimitingException("MyPolicy", result); + + exception.PolicyName.ShouldBe("MyPolicy"); + exception.Result.ShouldBeSameAs(result); + } +} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2EarlyBreakTestModule.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2EarlyBreakTestModule.cs new file mode 100644 index 0000000000..b36b9778cd --- /dev/null +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2EarlyBreakTestModule.cs @@ -0,0 +1,102 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Volo.Abp.Autofac; +using Volo.Abp.ExceptionHandling; +using Volo.Abp.Modularity; + +namespace Volo.Abp.OperationRateLimiting; + +/// +/// A mock store that simulates a multi-rule Phase 2 race condition: +/// - GetAsync always reports quota available (Phase 1 passes for all rules). +/// - IncrementAsync succeeds for the first call, fails on the second call +/// (simulating a concurrent race on Rule2), and tracks total increment calls +/// so tests can verify that Rule3 was never incremented (early break). +/// +internal class MultiRuleRaceConditionSimulatorStore : IOperationRateLimitingStore +{ + private int _incrementCallCount; + + /// + /// Total number of IncrementAsync calls made. + /// + public int IncrementCallCount => _incrementCallCount; + + public Task GetAsync(string key, TimeSpan duration, int maxCount) + { + return Task.FromResult(new OperationRateLimitingStoreResult + { + IsAllowed = true, + CurrentCount = 0, + MaxCount = maxCount + }); + } + + public Task IncrementAsync(string key, TimeSpan duration, int maxCount) + { + var callIndex = Interlocked.Increment(ref _incrementCallCount); + + if (callIndex == 2) + { + // Second rule: simulate concurrent race - another request consumed the last slot. + return Task.FromResult(new OperationRateLimitingStoreResult + { + IsAllowed = false, + CurrentCount = maxCount, + MaxCount = maxCount, + RetryAfter = duration + }); + } + + // First rule (and any others if early break fails): succeed. + return Task.FromResult(new OperationRateLimitingStoreResult + { + IsAllowed = true, + CurrentCount = 1, + MaxCount = maxCount + }); + } + + public Task ResetAsync(string key) + { + return Task.CompletedTask; + } +} + +[DependsOn( + typeof(AbpOperationRateLimitingModule), + typeof(AbpExceptionHandlingModule), + typeof(AbpTestBaseModule), + typeof(AbpAutofacModule) +)] +public class AbpOperationRateLimitingPhase2EarlyBreakTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.Replace( + ServiceDescriptor.Singleton()); + + Configure(options => + { + // 3-rule composite policy: all PartitionByParameter with different durations + // so they generate unique cache keys and don't trigger duplicate rule validation. + options.AddPolicy("TestMultiRuleRacePolicy", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(2), maxCount: 5) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(3), maxCount: 5) + .PartitionByParameter()); + }); + }); + } +} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2RaceTestModule.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2RaceTestModule.cs new file mode 100644 index 0000000000..c60381c774 --- /dev/null +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2RaceTestModule.cs @@ -0,0 +1,68 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Volo.Abp.Autofac; +using Volo.Abp.ExceptionHandling; +using Volo.Abp.Modularity; + +namespace Volo.Abp.OperationRateLimiting; + +/// +/// A mock store that simulates a concurrent race condition: +/// - GetAsync always says the quota is available (Phase 1 checks pass). +/// - IncrementAsync always says the quota is exhausted (Phase 2 finds another request consumed it). +/// +internal class RaceConditionSimulatorStore : IOperationRateLimitingStore +{ + public Task GetAsync(string key, TimeSpan duration, int maxCount) + { + return Task.FromResult(new OperationRateLimitingStoreResult + { + IsAllowed = true, + CurrentCount = 0, + MaxCount = maxCount + }); + } + + public Task IncrementAsync(string key, TimeSpan duration, int maxCount) + { + // Simulate: between Phase 1 and Phase 2 another concurrent request consumed the last slot. + return Task.FromResult(new OperationRateLimitingStoreResult + { + IsAllowed = false, + CurrentCount = maxCount, + MaxCount = maxCount, + RetryAfter = duration + }); + } + + public Task ResetAsync(string key) + { + return Task.CompletedTask; + } +} + +[DependsOn( + typeof(AbpOperationRateLimitingModule), + typeof(AbpExceptionHandlingModule), + typeof(AbpTestBaseModule), + typeof(AbpAutofacModule) +)] +public class AbpOperationRateLimitingPhase2RaceTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.Replace( + ServiceDescriptor.Transient()); + + Configure(options => + { + options.AddPolicy("TestRacePolicy", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) + .PartitionByParameter(); + }); + }); + } +} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs new file mode 100644 index 0000000000..cd436546f1 --- /dev/null +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs @@ -0,0 +1,187 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Volo.Abp.AspNetCore.WebClientInfo; +using Volo.Abp.Autofac; +using Volo.Abp.ExceptionHandling; +using Volo.Abp.Modularity; + +namespace Volo.Abp.OperationRateLimiting; + +[DependsOn( + typeof(AbpOperationRateLimitingModule), + typeof(AbpExceptionHandlingModule), + typeof(AbpTestBaseModule), + typeof(AbpAutofacModule) +)] +public class AbpOperationRateLimitingTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + var mockWebClientInfoProvider = Substitute.For(); + mockWebClientInfoProvider.ClientIpAddress.Returns("127.0.0.1"); + context.Services.AddSingleton(mockWebClientInfoProvider); + + Configure(options => + { + options.AddPolicy("TestSimple", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) + .PartitionByParameter(); + }); + + options.AddPolicy("TestUserBased", policy => + { + policy.WithFixedWindow(TimeSpan.FromMinutes(30), maxCount: 5) + .PartitionByCurrentUser(); + }); + + options.AddPolicy("TestComposite", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromDays(1), maxCount: 10) + .PartitionByCurrentUser()); + }); + + options.AddPolicy("TestCustomErrorCode", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) + .PartitionByParameter() + .WithErrorCode("Test:CustomError"); + }); + + options.AddPolicy("TestTenantBased", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) + .PartitionByCurrentTenant(); + }); + + options.AddPolicy("TestClientIp", policy => + { + policy.WithFixedWindow(TimeSpan.FromMinutes(15), maxCount: 10) + .PartitionByClientIp(); + }); + + options.AddPolicy("TestEmailBased", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) + .PartitionByEmail(); + }); + + options.AddPolicy("TestPhoneNumberBased", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) + .PartitionByPhoneNumber(); + }); + + // Composite where Rule2 triggers before Rule1 (to test no-wasted-increment) + options.AddPolicy("TestCompositeRule2First", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) + .PartitionByCurrentUser()); + }); + + // Composite: ByParameter + ByClientIp (different partition types, no auth) + options.AddPolicy("TestCompositeParamIp", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) + .PartitionByClientIp()); + }); + + // Composite: Triple - ByParameter + ByCurrentUser + ByClientIp + options.AddPolicy("TestCompositeTriple", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 4) + .PartitionByCurrentUser()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) + .PartitionByClientIp()); + }); + + // Fix #6: policy where both rules block simultaneously with different RetryAfter durations. + // Used to verify that Phase 1 checks ALL rules and reports the maximum RetryAfter. + // Rule0: 5-minute window → RetryAfter ~5 min when full + // Rule1: 2-hour window → RetryAfter ~2 hr when full + options.AddPolicy("TestCompositeMaxRetryAfter", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 1) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(2), maxCount: 1) + .PartitionByParameter()); + }); + + // Fix #6: policy where only Rule0 blocks but Rule1 is still within limit. + // Used to verify that RuleResults contains all rules, not just the blocking one. + options.AddPolicy("TestCompositePartialBlock", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 1) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) + .PartitionByParameter()); + }); + + // Ban policy: maxCount=0 should always deny + options.AddPolicy("TestBanPolicy", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 0) + .PartitionByParameter(); + }); + + // Custom resolver: combines Parameter + a static prefix to simulate multi-value key + options.AddPolicy("TestCustomResolver", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) + .PartitionBy(ctx => Task.FromResult($"action:{ctx.Parameter}")); + }); + + // Custom resolver returning null - should throw + options.AddPolicy("TestCustomResolverNull", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) + .PartitionBy(ctx => Task.FromResult(null!)); + }); + + // Multi-tenant: ByParameter with tenant isolation - same param, different tenants = different counters + options.AddPolicy("TestMultiTenantByParameter", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) + .WithMultiTenancy() + .PartitionByParameter(); + }); + + // Multi-tenant: ByClientIp (global) - same IP, different tenants = same counter + options.AddPolicy("TestMultiTenantByClientIp", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) + .PartitionByClientIp(); + }); + }); + } +} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/DistributedCacheOperationRateLimitingStore_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/DistributedCacheOperationRateLimitingStore_Tests.cs new file mode 100644 index 0000000000..b612419e48 --- /dev/null +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/DistributedCacheOperationRateLimitingStore_Tests.cs @@ -0,0 +1,135 @@ +using System; +using System.Threading.Tasks; +using Shouldly; +using Xunit; + +namespace Volo.Abp.OperationRateLimiting; + +public class DistributedCacheOperationRateLimitingStore_Tests : OperationRateLimitingTestBase +{ + private readonly IOperationRateLimitingStore _store; + + public DistributedCacheOperationRateLimitingStore_Tests() + { + _store = GetRequiredService(); + } + + [Fact] + public async Task Should_Create_New_Window_On_First_Request() + { + var key = $"store-new-{Guid.NewGuid()}"; + var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 5); + + result.IsAllowed.ShouldBeTrue(); + result.CurrentCount.ShouldBe(1); + result.MaxCount.ShouldBe(5); + result.RetryAfter.ShouldBeNull(); + } + + [Fact] + public async Task Should_Increment_Within_Window() + { + var key = $"store-incr-{Guid.NewGuid()}"; + + await _store.IncrementAsync(key, TimeSpan.FromHours(1), 5); + var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 5); + + result.IsAllowed.ShouldBeTrue(); + result.CurrentCount.ShouldBe(2); + } + + [Fact] + public async Task Should_Reject_When_MaxCount_Reached() + { + var key = $"store-max-{Guid.NewGuid()}"; + + await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); + await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); + var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); + + result.IsAllowed.ShouldBeFalse(); + result.CurrentCount.ShouldBe(2); + result.RetryAfter.ShouldNotBeNull(); + result.RetryAfter!.Value.TotalSeconds.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task Should_Reset_Counter() + { + var key = $"store-reset-{Guid.NewGuid()}"; + + await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); + await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); + + // At max now + var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); + result.IsAllowed.ShouldBeFalse(); + + // Reset + await _store.ResetAsync(key); + + // Should be allowed again + result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); + result.IsAllowed.ShouldBeTrue(); + result.CurrentCount.ShouldBe(1); + } + + [Fact] + public async Task Should_Get_Status_Without_Incrementing() + { + var key = $"store-get-{Guid.NewGuid()}"; + + await _store.IncrementAsync(key, TimeSpan.FromHours(1), 5); + + var result = await _store.GetAsync(key, TimeSpan.FromHours(1), 5); + result.IsAllowed.ShouldBeTrue(); + result.CurrentCount.ShouldBe(1); + + // Get again should still be 1 (no increment) + result = await _store.GetAsync(key, TimeSpan.FromHours(1), 5); + result.CurrentCount.ShouldBe(1); + } + + [Fact] + public async Task Should_Not_Isolate_By_Tenant_At_Store_Level() + { + // Tenant isolation is now handled at the rule level (BuildStoreKey), + // not at the store level. The store treats keys as opaque strings. + var key = $"store-tenant-{Guid.NewGuid()}"; + + await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); + await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); + + var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); + result.IsAllowed.ShouldBeFalse(); + + // Same key, same counter regardless of tenant context + result = await _store.GetAsync(key, TimeSpan.FromHours(1), 2); + result.IsAllowed.ShouldBeFalse(); + result.CurrentCount.ShouldBe(2); + } + + [Fact] + public async Task Should_Deny_Immediately_When_MaxCount_Is_Zero_Increment() + { + var key = $"store-zero-incr-{Guid.NewGuid()}"; + var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 0); + + result.IsAllowed.ShouldBeFalse(); + result.CurrentCount.ShouldBe(0); + result.MaxCount.ShouldBe(0); + result.RetryAfter.ShouldBeNull(); + } + + [Fact] + public async Task Should_Deny_Immediately_When_MaxCount_Is_Zero_Get() + { + var key = $"store-zero-get-{Guid.NewGuid()}"; + var result = await _store.GetAsync(key, TimeSpan.FromHours(1), 0); + + result.IsAllowed.ShouldBeFalse(); + result.CurrentCount.ShouldBe(0); + result.MaxCount.ShouldBe(0); + result.RetryAfter.ShouldBeNull(); + } +} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingCheckerFixes_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingCheckerFixes_Tests.cs new file mode 100644 index 0000000000..fce15fa466 --- /dev/null +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingCheckerFixes_Tests.cs @@ -0,0 +1,197 @@ +using System; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Testing; +using Xunit; + +namespace Volo.Abp.OperationRateLimiting; + +/// +/// Tests for Fix #6: Phase 1 in CheckAsync now checks ALL rules before throwing, +/// so RetryAfter is the maximum across all blocking rules and RuleResults is complete. +/// +public class OperationRateLimitingCheckerPhase1_Tests : OperationRateLimitingTestBase +{ + private readonly IOperationRateLimitingChecker _checker; + + public OperationRateLimitingCheckerPhase1_Tests() + { + _checker = GetRequiredService(); + } + + [Fact] + public async Task Should_Report_Max_RetryAfter_When_Multiple_Rules_Block() + { + // TestCompositeMaxRetryAfter: Rule0 (5-min window, max=1), Rule1 (2-hr window, max=1) + // Both rules use PartitionByParameter with the same key, so one request exhausts both. + var param = $"max-retry-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // First request: both rules go from 0 to 1 (exhausted, since maxCount=1) + await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); + + // Second request: both Rule0 and Rule1 are blocking. + // Phase 1 checks all rules → RetryAfter must be the larger one (~2 hours). + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); + }); + + // RetryAfter should be at least 1 hour (i.e., from Rule1's 2-hour window, not Rule0's 5-min window) + exception.Result.RetryAfter.ShouldNotBeNull(); + exception.Result.RetryAfter!.Value.ShouldBeGreaterThan(TimeSpan.FromHours(1)); + } + + [Fact] + public async Task Should_Include_All_Rules_In_RuleResults_When_Multiple_Rules_Block() + { + var param = $"all-rules-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // Exhaust both rules + await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); + + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); + }); + + // Both rules must appear in RuleResults (not just the first blocking one) + exception.Result.RuleResults.ShouldNotBeNull(); + exception.Result.RuleResults!.Count.ShouldBe(2); + exception.Result.RuleResults[0].IsAllowed.ShouldBeFalse(); + exception.Result.RuleResults[1].IsAllowed.ShouldBeFalse(); + } + + [Fact] + public async Task Should_Include_Non_Blocking_Rules_In_RuleResults() + { + // TestCompositePartialBlock: Rule0 (max=1) blocks, Rule1 (max=100) is still within limit. + // RuleResults must contain BOTH rules so callers get the full picture. + var param = $"partial-block-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // Exhaust only Rule0 (max=1) + await _checker.CheckAsync("TestCompositePartialBlock", context); + + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestCompositePartialBlock", context); + }); + + exception.Result.RuleResults.ShouldNotBeNull(); + exception.Result.RuleResults!.Count.ShouldBe(2); + + // Rule0 is blocking + exception.Result.RuleResults[0].IsAllowed.ShouldBeFalse(); + exception.Result.RuleResults[0].MaxCount.ShouldBe(1); + + // Rule1 is still allowed (only 1/100 used), but is still present in results + exception.Result.RuleResults[1].IsAllowed.ShouldBeTrue(); + exception.Result.RuleResults[1].MaxCount.ShouldBe(100); + exception.Result.RuleResults[1].RemainingCount.ShouldBe(99); + + // The overall RetryAfter comes only from the blocking Rule0 + exception.Result.RetryAfter.ShouldNotBeNull(); + exception.Result.RetryAfter!.Value.TotalMinutes.ShouldBeLessThan(61); // ~1 hour from Rule0 + } +} + +/// +/// Tests for Phase 2 early break: when a multi-rule policy encounters a race condition +/// in Phase 2 (Rule2 fails), Rule3 should NOT be incremented. +/// Uses a mock store where IncrementAsync fails on the 2nd call. +/// +public class OperationRateLimitingCheckerPhase2EarlyBreak_Tests + : AbpIntegratedTest +{ + protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) + { + options.UseAutofac(); + } + + [Fact] + public async Task Should_Not_Increment_Remaining_Rules_After_Phase2_Failure() + { + // 3-rule policy. Mock store: Rule1 increment succeeds, Rule2 increment fails (race), + // Rule3 should NOT be incremented due to early break. + var checker = GetRequiredService(); + var store = (MultiRuleRaceConditionSimulatorStore)GetRequiredService(); + var context = new OperationRateLimitingContext { Parameter = "early-break-test" }; + + var exception = await Assert.ThrowsAsync(async () => + { + await checker.CheckAsync("TestMultiRuleRacePolicy", context); + }); + + exception.PolicyName.ShouldBe("TestMultiRuleRacePolicy"); + exception.Result.IsAllowed.ShouldBeFalse(); + + // Key assertion: only 2 IncrementAsync calls were made (Rule1 + Rule2). + // Rule3 was skipped (used CheckAsync instead) due to early break. + store.IncrementCallCount.ShouldBe(2); + } + + [Fact] + public async Task Should_Include_All_Rule_Results_Despite_Early_Break() + { + // Even with early break, the aggregated result should contain all 3 rules + // (Rule3 via CheckAsync instead of AcquireAsync). + var checker = GetRequiredService(); + var context = new OperationRateLimitingContext { Parameter = $"all-results-{Guid.NewGuid()}" }; + + var exception = await Assert.ThrowsAsync(async () => + { + await checker.CheckAsync("TestMultiRuleRacePolicy", context); + }); + + exception.Result.RuleResults.ShouldNotBeNull(); + exception.Result.RuleResults!.Count.ShouldBe(3); + } +} + +/// +/// Tests for Fix #1: Phase 2 in CheckAsync now checks the result of AcquireAsync. +/// Uses a mock store that simulates a concurrent race condition: +/// GetAsync (Phase 1) always reports quota available, but IncrementAsync (Phase 2) returns denied. +/// +public class OperationRateLimitingCheckerPhase2Race_Tests + : AbpIntegratedTest +{ + protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) + { + options.UseAutofac(); + } + + [Fact] + public async Task Should_Throw_When_Phase2_Increment_Returns_Denied_Due_To_Race() + { + // The mock store always returns IsAllowed=true in GetAsync (Phase 1 passes) + // but always returns IsAllowed=false in IncrementAsync (simulates concurrent exhaustion). + // Before Fix #1, CheckAsync would silently succeed. After the fix it must throw. + var checker = GetRequiredService(); + var context = new OperationRateLimitingContext { Parameter = "race-test" }; + + var exception = await Assert.ThrowsAsync(async () => + { + await checker.CheckAsync("TestRacePolicy", context); + }); + + exception.PolicyName.ShouldBe("TestRacePolicy"); + exception.Result.IsAllowed.ShouldBeFalse(); + exception.HttpStatusCode.ShouldBe(429); + } + + [Fact] + public async Task IsAllowedAsync_Should_Not_Be_Affected_By_Phase2_Fix() + { + // IsAllowedAsync is read-only and does not call IncrementAsync, + // so it should not be affected by the mock store's deny-on-increment behavior. + var checker = GetRequiredService(); + var context = new OperationRateLimitingContext { Parameter = "is-allowed-race" }; + + // Should return true because GetAsync always returns allowed in the mock store + var allowed = await checker.IsAllowedAsync("TestRacePolicy", context); + allowed.ShouldBeTrue(); + } +} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs new file mode 100644 index 0000000000..fd3d9f4214 --- /dev/null +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs @@ -0,0 +1,798 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Security.Claims; +using Xunit; + +namespace Volo.Abp.OperationRateLimiting; + +public class OperationRateLimitingChecker_Tests : OperationRateLimitingTestBase +{ + private readonly IOperationRateLimitingChecker _checker; + + public OperationRateLimitingChecker_Tests() + { + _checker = GetRequiredService(); + } + + [Fact] + public async Task Should_Allow_Within_Limit() + { + var context = new OperationRateLimitingContext { Parameter = "test@example.com" }; + + // Should not throw for 3 requests (max is 3) + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + } + + [Fact] + public async Task Should_Reject_When_Exceeded() + { + var param = $"exceed-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestSimple", context); + }); + + exception.PolicyName.ShouldBe("TestSimple"); + exception.Result.IsAllowed.ShouldBeFalse(); + exception.HttpStatusCode.ShouldBe(429); + exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimit); + } + + [Fact] + public async Task Should_Return_Correct_RemainingCount() + { + var param = $"remaining-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + var status = await _checker.GetStatusAsync("TestSimple", context); + status.IsAllowed.ShouldBeTrue(); + status.RemainingCount.ShouldBe(3); + status.CurrentCount.ShouldBe(0); + + // Increment once + await _checker.CheckAsync("TestSimple", context); + + status = await _checker.GetStatusAsync("TestSimple", context); + status.IsAllowed.ShouldBeTrue(); + status.RemainingCount.ShouldBe(2); + status.CurrentCount.ShouldBe(1); + } + + [Fact] + public async Task Should_Return_Correct_RetryAfter() + { + var param = $"retry-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestSimple", context); + }); + + exception.Result.RetryAfter.ShouldNotBeNull(); + exception.Result.RetryAfter!.Value.TotalSeconds.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task Should_Handle_Composite_Policy_All_Pass() + { + var userId = Guid.NewGuid(); + + using (var scope = ServiceProvider.CreateScope()) + { + var principalAccessor = scope.ServiceProvider.GetRequiredService(); + var claimsPrincipal = CreateClaimsPrincipal(userId); + + using (principalAccessor.Change(claimsPrincipal)) + { + var checker = scope.ServiceProvider.GetRequiredService(); + var context = new OperationRateLimitingContext { Parameter = $"composite-{Guid.NewGuid()}" }; + + // Should pass: both rules within limits + await checker.CheckAsync("TestComposite", context); + await checker.CheckAsync("TestComposite", context); + await checker.CheckAsync("TestComposite", context); + } + } + } + + [Fact] + public async Task Should_Reject_Composite_Policy_When_Any_Rule_Exceeds() + { + var userId = Guid.NewGuid(); + + using (var scope = ServiceProvider.CreateScope()) + { + var principalAccessor = scope.ServiceProvider.GetRequiredService(); + var claimsPrincipal = CreateClaimsPrincipal(userId); + + using (principalAccessor.Change(claimsPrincipal)) + { + var checker = scope.ServiceProvider.GetRequiredService(); + var param = $"composite-reject-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + await checker.CheckAsync("TestComposite", context); + await checker.CheckAsync("TestComposite", context); + await checker.CheckAsync("TestComposite", context); + + // 4th request: Rule1 (max 3 per hour by parameter) should fail + var exception = await Assert.ThrowsAsync(async () => + { + await checker.CheckAsync("TestComposite", context); + }); + + exception.PolicyName.ShouldBe("TestComposite"); + } + } + } + + [Fact] + public async Task Should_Reset_Counter() + { + var param = $"reset-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + // Should be at limit + await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestSimple", context); + }); + + // Reset + await _checker.ResetAsync("TestSimple", context); + + // Should be allowed again + await _checker.CheckAsync("TestSimple", context); + } + + [Fact] + public async Task Should_Use_Custom_ErrorCode() + { + var param = $"custom-error-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + await _checker.CheckAsync("TestCustomErrorCode", context); + await _checker.CheckAsync("TestCustomErrorCode", context); + + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestCustomErrorCode", context); + }); + + exception.Code.ShouldBe("Test:CustomError"); + } + + [Fact] + public async Task Should_Throw_For_Unknown_Policy() + { + await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("NonExistentPolicy"); + }); + } + + [Fact] + public async Task Should_Skip_When_Disabled() + { + var options = GetRequiredService>(); + var originalValue = options.Value.IsEnabled; + + try + { + options.Value.IsEnabled = false; + + var param = $"disabled-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // Should pass unlimited times + for (var i = 0; i < 100; i++) + { + await _checker.CheckAsync("TestSimple", context); + } + } + finally + { + options.Value.IsEnabled = originalValue; + } + } + + [Fact] + public async Task Should_Work_With_IsAllowedAsync() + { + var param = $"is-allowed-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // IsAllowedAsync does not consume quota + (await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeTrue(); + (await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeTrue(); + + // Status should still show 0 consumed + var status = await _checker.GetStatusAsync("TestSimple", context); + status.CurrentCount.ShouldBe(0); + status.RemainingCount.ShouldBe(3); + + // Now consume all + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + (await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeFalse(); + } + + [Fact] + public async Task Should_Partition_By_Different_Parameters() + { + var param1 = $"param1-{Guid.NewGuid()}"; + var param2 = $"param2-{Guid.NewGuid()}"; + + var context1 = new OperationRateLimitingContext { Parameter = param1 }; + var context2 = new OperationRateLimitingContext { Parameter = param2 }; + + // Consume all for param1 + await _checker.CheckAsync("TestSimple", context1); + await _checker.CheckAsync("TestSimple", context1); + await _checker.CheckAsync("TestSimple", context1); + + // param2 should still be allowed + await _checker.CheckAsync("TestSimple", context2); + (await _checker.IsAllowedAsync("TestSimple", context2)).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Support_ExtraProperties_In_Exception_Data() + { + var param = $"extra-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext + { + Parameter = param, + ExtraProperties = + { + ["Email"] = "test@example.com", + ["UserId"] = "user-123" + } + }; + + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestSimple", context); + }); + + exception.Data["Email"].ShouldBe("test@example.com"); + exception.Data["UserId"].ShouldBe("user-123"); + exception.Data["PolicyName"].ShouldBe("TestSimple"); + exception.Data["MaxCount"].ShouldBe(3); + } + + [Fact] + public async Task Should_Partition_By_Email_Via_Parameter() + { + var email = $"email-param-{Guid.NewGuid()}@example.com"; + var context = new OperationRateLimitingContext { Parameter = email }; + + await _checker.CheckAsync("TestEmailBased", context); + await _checker.CheckAsync("TestEmailBased", context); + await _checker.CheckAsync("TestEmailBased", context); + + await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestEmailBased", context); + }); + } + + [Fact] + public async Task Should_Partition_By_Email_Via_CurrentUser_Fallback() + { + var userId = Guid.NewGuid(); + + using (var scope = ServiceProvider.CreateScope()) + { + var principalAccessor = scope.ServiceProvider.GetRequiredService(); + var claimsPrincipal = CreateClaimsPrincipal(userId); + + using (principalAccessor.Change(claimsPrincipal)) + { + var checker = scope.ServiceProvider.GetRequiredService(); + + // No Parameter set, should fall back to ICurrentUser.Email + var context = new OperationRateLimitingContext(); + + await checker.CheckAsync("TestEmailBased", context); + await checker.CheckAsync("TestEmailBased", context); + await checker.CheckAsync("TestEmailBased", context); + + await Assert.ThrowsAsync(async () => + { + await checker.CheckAsync("TestEmailBased", context); + }); + } + } + } + + [Fact] + public async Task Should_Partition_By_PhoneNumber_Via_Parameter() + { + var phone = $"phone-param-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = phone }; + + await _checker.CheckAsync("TestPhoneNumberBased", context); + await _checker.CheckAsync("TestPhoneNumberBased", context); + await _checker.CheckAsync("TestPhoneNumberBased", context); + + await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestPhoneNumberBased", context); + }); + } + + [Fact] + public async Task Should_Partition_By_PhoneNumber_Via_CurrentUser_Fallback() + { + var userId = Guid.NewGuid(); + + using (var scope = ServiceProvider.CreateScope()) + { + var principalAccessor = scope.ServiceProvider.GetRequiredService(); + var claimsPrincipal = CreateClaimsPrincipal(userId); + + using (principalAccessor.Change(claimsPrincipal)) + { + var checker = scope.ServiceProvider.GetRequiredService(); + + // No Parameter set, should fall back to ICurrentUser.PhoneNumber + var context = new OperationRateLimitingContext(); + + await checker.CheckAsync("TestPhoneNumberBased", context); + await checker.CheckAsync("TestPhoneNumberBased", context); + await checker.CheckAsync("TestPhoneNumberBased", context); + + await Assert.ThrowsAsync(async () => + { + await checker.CheckAsync("TestPhoneNumberBased", context); + }); + } + } + } + + [Fact] + public async Task Should_Throw_When_Email_Not_Available() + { + // No Parameter and no authenticated user + var context = new OperationRateLimitingContext(); + + await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestEmailBased", context); + }); + } + + [Fact] + public async Task Should_Not_Waste_Rule1_Count_When_Rule2_Blocks() + { + // TestCompositeRule2First: Rule1 (Parameter, 5/hour), Rule2 (CurrentUser, 2/hour) + // Rule2 triggers at 2. Rule1 should NOT be incremented for blocked requests. + var userId = Guid.NewGuid(); + + using (var scope = ServiceProvider.CreateScope()) + { + var principalAccessor = scope.ServiceProvider.GetRequiredService(); + var claimsPrincipal = CreateClaimsPrincipal(userId); + + using (principalAccessor.Change(claimsPrincipal)) + { + var checker = scope.ServiceProvider.GetRequiredService(); + var param = $"no-waste-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // 2 successful requests (Rule1: 2/5, Rule2: 2/2) + await checker.CheckAsync("TestCompositeRule2First", context); + await checker.CheckAsync("TestCompositeRule2First", context); + + // 3rd request: Rule2 blocks (2/2 at max) + await Assert.ThrowsAsync(async () => + { + await checker.CheckAsync("TestCompositeRule2First", context); + }); + + // Verify Rule1 was NOT incremented for the blocked request + // Rule1 should still be at 2/5, not 3/5 + var status = await checker.GetStatusAsync("TestCompositeRule2First", context); + // GetStatusAsync returns the most restrictive rule (Rule2 at 2/2) + // But we can verify Rule1 by checking RuleResults + status.RuleResults.ShouldNotBeNull(); + status.RuleResults!.Count.ShouldBe(2); + + // Rule1 (index 0): should be 2/5, remaining 3 + status.RuleResults[0].RemainingCount.ShouldBe(3); + status.RuleResults[0].MaxCount.ShouldBe(5); + + // Rule2 (index 1): should be 2/2, remaining 0 + status.RuleResults[1].RemainingCount.ShouldBe(0); + status.RuleResults[1].MaxCount.ShouldBe(2); + } + } + } + + [Fact] + public async Task Should_Composite_ParamIp_Ip_Triggers_First() + { + // TestCompositeParamIp: Rule1 (Parameter, 5/hour), Rule2 (ClientIp, 3/hour) + // IP limit (3) is lower, should trigger first + var param = $"param-ip-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // 3 successful requests + await _checker.CheckAsync("TestCompositeParamIp", context); + await _checker.CheckAsync("TestCompositeParamIp", context); + await _checker.CheckAsync("TestCompositeParamIp", context); + + // 4th: IP rule blocks (3/3) + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestCompositeParamIp", context); + }); + + exception.PolicyName.ShouldBe("TestCompositeParamIp"); + + // Verify counts: Rule1 should be 3/5, Rule2 should be 3/3 + var status = await _checker.GetStatusAsync("TestCompositeParamIp", context); + status.RuleResults.ShouldNotBeNull(); + status.RuleResults!.Count.ShouldBe(2); + + status.RuleResults[0].RemainingCount.ShouldBe(2); // Parameter: 3/5, remaining 2 + status.RuleResults[0].MaxCount.ShouldBe(5); + status.RuleResults[1].RemainingCount.ShouldBe(0); // IP: 3/3, remaining 0 + status.RuleResults[1].MaxCount.ShouldBe(3); + } + + [Fact] + public async Task Should_Composite_ParamIp_Different_Params_Share_Ip() + { + // Different parameters should have independent Rule1 counters + // but share the same Rule2 (IP) counter + var param1 = $"share-ip-1-{Guid.NewGuid()}"; + var param2 = $"share-ip-2-{Guid.NewGuid()}"; + var context1 = new OperationRateLimitingContext { Parameter = param1 }; + var context2 = new OperationRateLimitingContext { Parameter = param2 }; + + // 2 requests with param1 + await _checker.CheckAsync("TestCompositeParamIp", context1); + await _checker.CheckAsync("TestCompositeParamIp", context1); + + // 1 request with param2 (IP counter is now at 3/3) + await _checker.CheckAsync("TestCompositeParamIp", context2); + + // 4th request with param2: IP rule blocks (3/3 from combined) + await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestCompositeParamIp", context2); + }); + + // param1 Rule1 should be at 2/5 + var status1 = await _checker.GetStatusAsync("TestCompositeParamIp", context1); + status1.RuleResults![0].RemainingCount.ShouldBe(3); // Parameter: 2/5 + status1.RuleResults[0].MaxCount.ShouldBe(5); + + // param2 Rule1 should be at 1/5 + var status2 = await _checker.GetStatusAsync("TestCompositeParamIp", context2); + status2.RuleResults![0].RemainingCount.ShouldBe(4); // Parameter: 1/5 + status2.RuleResults[0].MaxCount.ShouldBe(5); + } + + [Fact] + public async Task Should_Composite_Triple_Lowest_Limit_Triggers_First() + { + // TestCompositeTriple: Rule1 (Parameter, 5/hour), Rule2 (User, 4/hour), Rule3 (IP, 3/hour) + // IP limit (3) is lowest, should trigger first + var userId = Guid.NewGuid(); + + using (var scope = ServiceProvider.CreateScope()) + { + var principalAccessor = scope.ServiceProvider.GetRequiredService(); + var claimsPrincipal = CreateClaimsPrincipal(userId); + + using (principalAccessor.Change(claimsPrincipal)) + { + var checker = scope.ServiceProvider.GetRequiredService(); + var param = $"triple-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // 3 successful requests + await checker.CheckAsync("TestCompositeTriple", context); + await checker.CheckAsync("TestCompositeTriple", context); + await checker.CheckAsync("TestCompositeTriple", context); + + // 4th: IP rule blocks (3/3) + await Assert.ThrowsAsync(async () => + { + await checker.CheckAsync("TestCompositeTriple", context); + }); + + // Verify all three rules + var status = await checker.GetStatusAsync("TestCompositeTriple", context); + status.RuleResults.ShouldNotBeNull(); + status.RuleResults!.Count.ShouldBe(3); + + status.RuleResults[0].RemainingCount.ShouldBe(2); // Parameter: 3/5 + status.RuleResults[0].MaxCount.ShouldBe(5); + status.RuleResults[1].RemainingCount.ShouldBe(1); // User: 3/4 + status.RuleResults[1].MaxCount.ShouldBe(4); + status.RuleResults[2].RemainingCount.ShouldBe(0); // IP: 3/3 + status.RuleResults[2].MaxCount.ShouldBe(3); + } + } + } + + [Fact] + public async Task Should_Composite_Triple_No_Wasted_Increment_On_Block() + { + // When IP (Rule3) blocks, Rule1 and Rule2 should NOT be incremented + var userId = Guid.NewGuid(); + + using (var scope = ServiceProvider.CreateScope()) + { + var principalAccessor = scope.ServiceProvider.GetRequiredService(); + var claimsPrincipal = CreateClaimsPrincipal(userId); + + using (principalAccessor.Change(claimsPrincipal)) + { + var checker = scope.ServiceProvider.GetRequiredService(); + var param = $"triple-nowaste-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // 3 successful requests (all rules increment to 3) + await checker.CheckAsync("TestCompositeTriple", context); + await checker.CheckAsync("TestCompositeTriple", context); + await checker.CheckAsync("TestCompositeTriple", context); + + // Attempt 3 more blocked requests + for (var i = 0; i < 3; i++) + { + await Assert.ThrowsAsync(async () => + { + await checker.CheckAsync("TestCompositeTriple", context); + }); + } + + // Verify Rule1 and Rule2 were NOT incremented beyond 3 + var status = await checker.GetStatusAsync("TestCompositeTriple", context); + status.RuleResults![0].RemainingCount.ShouldBe(2); // Parameter: still 3/5 + status.RuleResults[1].RemainingCount.ShouldBe(1); // User: still 3/4 + status.RuleResults[2].RemainingCount.ShouldBe(0); // IP: still 3/3 + } + } + } + + [Fact] + public async Task Should_Composite_Reset_All_Rules() + { + // Verify reset clears all rules in a composite policy + var userId = Guid.NewGuid(); + + using (var scope = ServiceProvider.CreateScope()) + { + var principalAccessor = scope.ServiceProvider.GetRequiredService(); + var claimsPrincipal = CreateClaimsPrincipal(userId); + + using (principalAccessor.Change(claimsPrincipal)) + { + var checker = scope.ServiceProvider.GetRequiredService(); + var param = $"triple-reset-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // Exhaust IP limit + await checker.CheckAsync("TestCompositeTriple", context); + await checker.CheckAsync("TestCompositeTriple", context); + await checker.CheckAsync("TestCompositeTriple", context); + + await Assert.ThrowsAsync(async () => + { + await checker.CheckAsync("TestCompositeTriple", context); + }); + + // Reset + await checker.ResetAsync("TestCompositeTriple", context); + + // All rules should be cleared + var status = await checker.GetStatusAsync("TestCompositeTriple", context); + status.IsAllowed.ShouldBeTrue(); + status.RuleResults![0].RemainingCount.ShouldBe(5); // Parameter: 0/5 + status.RuleResults[1].RemainingCount.ShouldBe(4); // User: 0/4 + status.RuleResults[2].RemainingCount.ShouldBe(3); // IP: 0/3 + + // Should be able to use again + await checker.CheckAsync("TestCompositeTriple", context); + } + } + } + + [Fact] + public async Task Should_Throw_When_PhoneNumber_Not_Available() + { + // No Parameter and no authenticated user + var context = new OperationRateLimitingContext(); + + await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestPhoneNumberBased", context); + }); + } + + [Fact] + public async Task Should_Deny_First_Request_When_MaxCount_Is_Zero() + { + var context = new OperationRateLimitingContext { Parameter = $"ban-{Guid.NewGuid()}" }; + + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestBanPolicy", context); + }); + + exception.Result.IsAllowed.ShouldBeFalse(); + exception.Result.MaxCount.ShouldBe(0); + exception.Result.RetryAfter.ShouldBeNull(); + exception.HttpStatusCode.ShouldBe(429); + exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently); + } + + [Fact] + public async Task Should_IsAllowed_Return_False_When_MaxCount_Is_Zero() + { + var context = new OperationRateLimitingContext { Parameter = $"ban-allowed-{Guid.NewGuid()}" }; + + var allowed = await _checker.IsAllowedAsync("TestBanPolicy", context); + allowed.ShouldBeFalse(); + } + + [Fact] + public async Task Should_GetStatus_Show_Not_Allowed_When_MaxCount_Is_Zero() + { + var context = new OperationRateLimitingContext { Parameter = $"ban-status-{Guid.NewGuid()}" }; + + var status = await _checker.GetStatusAsync("TestBanPolicy", context); + status.IsAllowed.ShouldBeFalse(); + status.MaxCount.ShouldBe(0); + status.RemainingCount.ShouldBe(0); + status.RetryAfter.ShouldBeNull(); + } + + [Fact] + public async Task Should_Partition_By_Custom_Resolver() + { + // TestCustomResolver uses PartitionBy(ctx => $"action:{ctx.Parameter}") + // Two different parameters => independent counters + var param1 = $"op1-{Guid.NewGuid()}"; + var param2 = $"op2-{Guid.NewGuid()}"; + + var ctx1 = new OperationRateLimitingContext { Parameter = param1 }; + var ctx2 = new OperationRateLimitingContext { Parameter = param2 }; + + // Exhaust param1's quota (max=2) + await _checker.CheckAsync("TestCustomResolver", ctx1); + await _checker.CheckAsync("TestCustomResolver", ctx1); + + await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestCustomResolver", ctx1); + }); + + // param2 should still be allowed + await _checker.CheckAsync("TestCustomResolver", ctx2); + (await _checker.IsAllowedAsync("TestCustomResolver", ctx2)).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Throw_When_Custom_Resolver_Returns_Null() + { + var context = new OperationRateLimitingContext { Parameter = "test" }; + + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestCustomResolverNull", context); + }); + + exception.Message.ShouldContain("Custom partition key resolver returned null or empty"); + } + + [Fact] + public void Should_Throw_When_Policy_Has_Duplicate_Rules() + { + var options = new AbpOperationRateLimitingOptions(); + + Assert.Throws(() => + { + options.AddPolicy("DuplicateRulePolicy", policy => + { + policy.AddRule(r => r.WithFixedWindow(TimeSpan.FromHours(1), 5).PartitionByParameter()); + policy.AddRule(r => r.WithFixedWindow(TimeSpan.FromHours(1), 5).PartitionByParameter()); + }); + }); + } + + + [Fact] + public async Task Should_Return_Correct_CurrentCount_In_RuleResults() + { + var param = $"current-count-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + var status = await _checker.GetStatusAsync("TestSimple", context); + status.RuleResults.ShouldNotBeNull(); + status.RuleResults!.Count.ShouldBe(1); + status.RuleResults[0].CurrentCount.ShouldBe(2); + status.RuleResults[0].RemainingCount.ShouldBe(1); + status.RuleResults[0].MaxCount.ShouldBe(3); + } + + [Fact] + public async Task ResetAsync_Should_Skip_When_Disabled() + { + var options = GetRequiredService>(); + var originalValue = options.Value.IsEnabled; + + try + { + var param = $"reset-disabled-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // Exhaust the quota + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + // Disable and call ResetAsync — should be a no-op (counter not actually reset) + options.Value.IsEnabled = false; + await _checker.ResetAsync("TestSimple", context); + + // Re-enable: quota should still be exhausted because reset was skipped + options.Value.IsEnabled = true; + await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestSimple", context); + }); + } + finally + { + options.Value.IsEnabled = originalValue; + } + } + + private static ClaimsPrincipal CreateClaimsPrincipal(Guid userId) + { + return new ClaimsPrincipal( + new ClaimsIdentity( + new[] + { + new Claim(AbpClaimTypes.UserId, userId.ToString()), + new Claim(AbpClaimTypes.Email, "test@example.com"), + new Claim(AbpClaimTypes.PhoneNumber, "1234567890") + }, + "TestAuth")); + } +} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingFrontendIntegration_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingFrontendIntegration_Tests.cs new file mode 100644 index 0000000000..6566bbccdb --- /dev/null +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingFrontendIntegration_Tests.cs @@ -0,0 +1,408 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.AspNetCore.ExceptionHandling; +using Volo.Abp.Localization; +using Xunit; + +namespace Volo.Abp.OperationRateLimiting; + +public class OperationRateLimitingFrontendIntegration_Tests : OperationRateLimitingTestBase +{ + private readonly IOperationRateLimitingChecker _checker; + private readonly IExceptionToErrorInfoConverter _errorInfoConverter; + private readonly IOperationRateLimitingFormatter _formatter; + + public OperationRateLimitingFrontendIntegration_Tests() + { + _checker = GetRequiredService(); + _errorInfoConverter = GetRequiredService(); + _formatter = GetRequiredService(); + } + + [Fact] + public async Task ErrorInfo_Should_Contain_Localized_Message_En() + { + using (CultureHelper.Use("en")) + { + var param = $"frontend-en-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestSimple", context); + }); + + var errorInfo = _errorInfoConverter.Convert(exception); + + // The localized message should contain "Operation rate limit exceeded" + errorInfo.Message.ShouldContain("Operation rate limit exceeded"); + errorInfo.Message.ShouldContain("minute(s)"); + } + } + + [Fact] + public async Task ErrorInfo_Should_Contain_Localized_Message_ZhHans() + { + using (CultureHelper.Use("zh-Hans")) + { + var param = $"frontend-zh-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestSimple", context); + }); + + var errorInfo = _errorInfoConverter.Convert(exception); + + // The localized message should be in Chinese + errorInfo.Message.ShouldContain("操作频率超出限制"); + errorInfo.Message.ShouldContain("分钟"); + } + } + + [Fact] + public async Task ErrorInfo_Should_Include_Structured_Data_For_Frontend() + { + var param = $"frontend-data-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext + { + Parameter = param, + ExtraProperties = + { + ["Email"] = "user@example.com" + } + }; + + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestSimple", context); + }); + + var errorInfo = _errorInfoConverter.Convert(exception); + + // Frontend receives error.code + errorInfo.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimit); + + // Frontend receives error.data for countdown timer and UI display + exception.Data["PolicyName"].ShouldBe("TestSimple"); + exception.Data["MaxCount"].ShouldBe(3); + exception.Data["CurrentCount"].ShouldBe(3); + exception.Data["RemainingCount"].ShouldBe(0); + + // RetryAfterSeconds: frontend uses this for countdown + var retryAfterSeconds = (int)exception.Data["RetryAfterSeconds"]!; + retryAfterSeconds.ShouldBeGreaterThan(0); + retryAfterSeconds.ShouldBeLessThanOrEqualTo(3600); // max 1 hour window + + // RetryAfterMinutes: frontend uses this for display + var retryAfterMinutes = (int)exception.Data["RetryAfterMinutes"]!; + retryAfterMinutes.ShouldBeGreaterThan(0); + + // RetryAfter: localized human-readable string + exception.Data["RetryAfter"].ShouldNotBeNull(); + exception.Data["RetryAfter"].ShouldBeOfType(); + + // WindowDurationSeconds: the configured window duration + var windowDurationSeconds = (int)exception.Data["WindowDurationSeconds"]!; + windowDurationSeconds.ShouldBe(3600); // 1 hour window + + // WindowDescription: localized human-readable window description (e.g. "1 hour(s)") + exception.Data["WindowDescription"].ShouldNotBeNull(); + exception.Data["WindowDescription"].ShouldBeOfType(); + + // RuleDetails: complete rule information for frontend + var ruleDetails = exception.Data["RuleDetails"].ShouldBeOfType>>(); + ruleDetails.Count.ShouldBe(1); + ruleDetails[0]["RuleName"].ShouldBe("TestSimple:Rule[3600s,3,Parameter]"); + ruleDetails[0]["MaxCount"].ShouldBe(3); + ruleDetails[0]["IsAllowed"].ShouldBe(false); + ruleDetails[0]["WindowDurationSeconds"].ShouldBe(3600); + ((string)ruleDetails[0]["WindowDescription"]).ShouldNotBeNullOrEmpty(); + ((int)ruleDetails[0]["RetryAfterSeconds"]).ShouldBeGreaterThan(0); + ((string)ruleDetails[0]["RetryAfter"]).ShouldNotBeNullOrEmpty(); + + // ExtraProperties passed through + exception.Data["Email"].ShouldBe("user@example.com"); + } + + [Fact] + public async Task GetStatusAsync_Should_Provide_Countdown_Data_For_Frontend() + { + var param = $"frontend-status-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // Before any requests: frontend can show "3 remaining" + var status = await _checker.GetStatusAsync("TestSimple", context); + status.IsAllowed.ShouldBeTrue(); + status.RemainingCount.ShouldBe(3); + status.MaxCount.ShouldBe(3); + status.CurrentCount.ShouldBe(0); + status.RetryAfter.ShouldBeNull(); + status.WindowDuration.ShouldBe(TimeSpan.FromHours(1)); + + // After 2 requests: frontend shows "1 remaining" + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + status = await _checker.GetStatusAsync("TestSimple", context); + status.IsAllowed.ShouldBeTrue(); + status.RemainingCount.ShouldBe(1); + status.MaxCount.ShouldBe(3); + status.CurrentCount.ShouldBe(2); + + // After exhausting limit: frontend shows countdown + await _checker.CheckAsync("TestSimple", context); + + status = await _checker.GetStatusAsync("TestSimple", context); + status.IsAllowed.ShouldBeFalse(); + status.RemainingCount.ShouldBe(0); + status.CurrentCount.ShouldBe(3); + status.RetryAfter.ShouldNotBeNull(); + status.RetryAfter!.Value.TotalSeconds.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task Custom_ErrorCode_Should_Appear_In_ErrorInfo() + { + var param = $"frontend-custom-code-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + await _checker.CheckAsync("TestCustomErrorCode", context); + await _checker.CheckAsync("TestCustomErrorCode", context); + + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestCustomErrorCode", context); + }); + + var errorInfo = _errorInfoConverter.Convert(exception); + + // Frontend can use error.code to decide which UI to show + errorInfo.Code.ShouldBe("Test:CustomError"); + } + + [Fact] + public void RetryAfterFormatter_Should_Format_Seconds() + { + using (CultureHelper.Use("en")) + { + _formatter.Format(TimeSpan.FromSeconds(30)).ShouldBe("30 second(s)"); + } + + using (CultureHelper.Use("zh-Hans")) + { + _formatter.Format(TimeSpan.FromSeconds(30)).ShouldBe("30 秒"); + } + } + + [Fact] + public void RetryAfterFormatter_Should_Format_Minutes() + { + using (CultureHelper.Use("en")) + { + _formatter.Format(TimeSpan.FromMinutes(15)).ShouldBe("15 minute(s)"); + } + + using (CultureHelper.Use("zh-Hans")) + { + _formatter.Format(TimeSpan.FromMinutes(15)).ShouldBe("15 分钟"); + } + } + + [Fact] + public void RetryAfterFormatter_Should_Format_MinutesAndSeconds() + { + using (CultureHelper.Use("en")) + { + // 70 seconds = 1 minute and 10 seconds + _formatter.Format(TimeSpan.FromSeconds(70)).ShouldBe("1 minute(s) and 10 second(s)"); + _formatter.Format(TimeSpan.FromSeconds(90)).ShouldBe("1 minute(s) and 30 second(s)"); + } + + using (CultureHelper.Use("zh-Hans")) + { + _formatter.Format(TimeSpan.FromSeconds(70)).ShouldBe("1 分钟 10 秒"); + _formatter.Format(TimeSpan.FromSeconds(90)).ShouldBe("1 分钟 30 秒"); + } + } + + [Fact] + public void RetryAfterFormatter_Should_Format_Hours() + { + using (CultureHelper.Use("en")) + { + _formatter.Format(TimeSpan.FromHours(2)).ShouldBe("2 hour(s)"); + } + + using (CultureHelper.Use("zh-Hans")) + { + _formatter.Format(TimeSpan.FromHours(2)).ShouldBe("2 小时"); + } + } + + [Fact] + public void RetryAfterFormatter_Should_Format_HoursAndMinutes() + { + using (CultureHelper.Use("en")) + { + _formatter.Format(TimeSpan.FromMinutes(90)).ShouldBe("1 hour(s) and 30 minute(s)"); + } + + using (CultureHelper.Use("zh-Hans")) + { + _formatter.Format(TimeSpan.FromMinutes(90)).ShouldBe("1 小时 30 分钟"); + } + } + + [Fact] + public void RetryAfterFormatter_Should_Format_Days() + { + using (CultureHelper.Use("en")) + { + _formatter.Format(TimeSpan.FromDays(1)).ShouldBe("1 day(s)"); + _formatter.Format(TimeSpan.FromDays(3)).ShouldBe("3 day(s)"); + } + + using (CultureHelper.Use("zh-Hans")) + { + _formatter.Format(TimeSpan.FromDays(1)).ShouldBe("1 天"); + _formatter.Format(TimeSpan.FromDays(3)).ShouldBe("3 天"); + } + } + + [Fact] + public void RetryAfterFormatter_Should_Format_DaysAndHours() + { + using (CultureHelper.Use("en")) + { + _formatter.Format(TimeSpan.FromHours(30)).ShouldBe("1 day(s) and 6 hour(s)"); + } + + using (CultureHelper.Use("zh-Hans")) + { + _formatter.Format(TimeSpan.FromHours(30)).ShouldBe("1 天 6 小时"); + } + } + + [Fact] + public void RetryAfterFormatter_Should_Format_Months() + { + using (CultureHelper.Use("en")) + { + _formatter.Format(TimeSpan.FromDays(30)).ShouldBe("1 month(s)"); + _formatter.Format(TimeSpan.FromDays(90)).ShouldBe("3 month(s)"); + } + + using (CultureHelper.Use("zh-Hans")) + { + _formatter.Format(TimeSpan.FromDays(30)).ShouldBe("1 个月"); + _formatter.Format(TimeSpan.FromDays(90)).ShouldBe("3 个月"); + } + } + + [Fact] + public void RetryAfterFormatter_Should_Format_MonthsAndDays() + { + using (CultureHelper.Use("en")) + { + _formatter.Format(TimeSpan.FromDays(45)).ShouldBe("1 month(s) and 15 day(s)"); + } + + using (CultureHelper.Use("zh-Hans")) + { + _formatter.Format(TimeSpan.FromDays(45)).ShouldBe("1 个月 15 天"); + } + } + + [Fact] + public void RetryAfterFormatter_Should_Format_Years() + { + using (CultureHelper.Use("en")) + { + _formatter.Format(TimeSpan.FromDays(365)).ShouldBe("1 year(s)"); + _formatter.Format(TimeSpan.FromDays(730)).ShouldBe("2 year(s)"); + } + + using (CultureHelper.Use("zh-Hans")) + { + _formatter.Format(TimeSpan.FromDays(365)).ShouldBe("1 年"); + _formatter.Format(TimeSpan.FromDays(730)).ShouldBe("2 年"); + } + } + + [Fact] + public void RetryAfterFormatter_Should_Format_YearsAndMonths() + { + using (CultureHelper.Use("en")) + { + // 1 year + 60 days = 1 year and 2 months + _formatter.Format(TimeSpan.FromDays(425)).ShouldBe("1 year(s) and 2 month(s)"); + } + + using (CultureHelper.Use("zh-Hans")) + { + _formatter.Format(TimeSpan.FromDays(425)).ShouldBe("1 年 2 个月"); + } + } + + [Fact] + public async Task Reset_Should_Allow_Frontend_To_Resume() + { + var param = $"frontend-reset-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // Exhaust limit + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + // Frontend shows "limit reached" + var status = await _checker.GetStatusAsync("TestSimple", context); + status.IsAllowed.ShouldBeFalse(); + + // After reset (e.g. CAPTCHA verified), frontend can resume + await _checker.ResetAsync("TestSimple", context); + + status = await _checker.GetStatusAsync("TestSimple", context); + status.IsAllowed.ShouldBeTrue(); + status.RemainingCount.ShouldBe(3); + status.CurrentCount.ShouldBe(0); + status.RetryAfter.ShouldBeNull(); + } + + [Fact] + public async Task IsAllowedAsync_Can_Be_Used_For_Frontend_PreCheck() + { + var param = $"frontend-precheck-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // Frontend precheck: button should be enabled + (await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeTrue(); + + // Consume all + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + // Frontend precheck: button should be disabled + (await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeFalse(); + + // IsAllowedAsync does NOT consume — calling again still returns false, not error + (await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeFalse(); + } +} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingMultiTenant_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingMultiTenant_Tests.cs new file mode 100644 index 0000000000..b55ffbc966 --- /dev/null +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingMultiTenant_Tests.cs @@ -0,0 +1,106 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Volo.Abp.OperationRateLimiting; + +/// +/// Verifies per-tenant isolation for tenant-scoped partition types and +/// global (cross-tenant) sharing for ClientIp partition type. +/// +public class OperationRateLimitingMultiTenant_Tests : OperationRateLimitingTestBase +{ + private readonly ICurrentTenant _currentTenant; + private readonly IOperationRateLimitingChecker _checker; + + private static readonly Guid TenantA = Guid.NewGuid(); + private static readonly Guid TenantB = Guid.NewGuid(); + + public OperationRateLimitingMultiTenant_Tests() + { + _currentTenant = GetRequiredService(); + _checker = GetRequiredService(); + } + + [Fact] + public async Task Should_Isolate_ByParameter_Between_Tenants() + { + // Same parameter value in different tenants should have independent counters. + var param = $"shared-param-{Guid.NewGuid()}"; + + using (_currentTenant.Change(TenantA)) + { + var ctx = new OperationRateLimitingContext { Parameter = param }; + await _checker.CheckAsync("TestMultiTenantByParameter", ctx); + await _checker.CheckAsync("TestMultiTenantByParameter", ctx); + + // Tenant A exhausted (max=2) + await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestMultiTenantByParameter", ctx); + }); + } + + using (_currentTenant.Change(TenantB)) + { + var ctx = new OperationRateLimitingContext { Parameter = param }; + + // Tenant B has its own counter and should still be allowed + await _checker.CheckAsync("TestMultiTenantByParameter", ctx); + (await _checker.IsAllowedAsync("TestMultiTenantByParameter", ctx)).ShouldBeTrue(); + } + } + + [Fact] + public async Task Should_Share_ByClientIp_Across_Tenants() + { + // ClientIp counters are global: requests from the same IP are counted together + // regardless of which tenant context is active. + // The mock IWebClientInfoProvider returns "127.0.0.1" for all requests. + + using (_currentTenant.Change(TenantA)) + { + var ctx = new OperationRateLimitingContext(); + await _checker.CheckAsync("TestMultiTenantByClientIp", ctx); + await _checker.CheckAsync("TestMultiTenantByClientIp", ctx); + } + + using (_currentTenant.Change(TenantB)) + { + var ctx = new OperationRateLimitingContext(); + + // Tenant B shares the same IP counter; should be at limit now + await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestMultiTenantByClientIp", ctx); + }); + } + } + + [Fact] + public async Task Should_Isolate_ByParameter_Host_Tenant_From_Named_Tenant() + { + // Host context (no tenant) and a specific tenant should have separate counters. + var param = $"host-vs-tenant-{Guid.NewGuid()}"; + + // Host context: exhaust quota + var hostCtx = new OperationRateLimitingContext { Parameter = param }; + await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx); + await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx); + await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx); + }); + + // Tenant A should have its own counter, unaffected by host + using (_currentTenant.Change(TenantA)) + { + var tenantCtx = new OperationRateLimitingContext { Parameter = param }; + await _checker.CheckAsync("TestMultiTenantByParameter", tenantCtx); + (await _checker.IsAllowedAsync("TestMultiTenantByParameter", tenantCtx)).ShouldBeTrue(); + } + } +} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs new file mode 100644 index 0000000000..6a503a6191 --- /dev/null +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs @@ -0,0 +1,258 @@ +using System; +using System.Threading.Tasks; +using Shouldly; +using Xunit; + +namespace Volo.Abp.OperationRateLimiting; + +public class OperationRateLimitingPolicyBuilder_Tests +{ + [Fact] + public void Should_Build_Simple_Policy() + { + var options = new AbpOperationRateLimitingOptions(); + options.AddPolicy("TestPolicy", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .PartitionByParameter(); + }); + + var policy = options.Policies["TestPolicy"]; + + policy.Name.ShouldBe("TestPolicy"); + policy.Rules.Count.ShouldBe(1); + policy.Rules[0].Duration.ShouldBe(TimeSpan.FromHours(1)); + policy.Rules[0].MaxCount.ShouldBe(5); + policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); + policy.ErrorCode.ShouldBeNull(); + } + + [Fact] + public void Should_Build_Composite_Policy() + { + var options = new AbpOperationRateLimitingOptions(); + options.AddPolicy("CompositePolicy", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromDays(1), maxCount: 10) + .PartitionByCurrentUser()); + }); + + var policy = options.Policies["CompositePolicy"]; + + policy.Name.ShouldBe("CompositePolicy"); + policy.Rules.Count.ShouldBe(2); + policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); + policy.Rules[0].MaxCount.ShouldBe(3); + policy.Rules[1].PartitionType.ShouldBe(OperationRateLimitingPartitionType.CurrentUser); + policy.Rules[1].MaxCount.ShouldBe(10); + } + + [Fact] + public void Should_Set_ErrorCode() + { + var options = new AbpOperationRateLimitingOptions(); + options.AddPolicy("ErrorPolicy", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) + .PartitionByParameter() + .WithErrorCode("App:Custom:Error"); + }); + + var policy = options.Policies["ErrorPolicy"]; + policy.ErrorCode.ShouldBe("App:Custom:Error"); + } + + [Fact] + public void Should_Build_Custom_Partition() + { + var options = new AbpOperationRateLimitingOptions(); + options.AddPolicy("CustomPolicy", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromMinutes(30), maxCount: 5) + .PartitionBy(ctx => Task.FromResult($"custom:{ctx.Parameter}"))); + }); + + var policy = options.Policies["CustomPolicy"]; + + policy.Rules.Count.ShouldBe(1); + policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Custom); + policy.Rules[0].CustomPartitionKeyResolver.ShouldNotBeNull(); + } + + [Fact] + public void Should_Support_All_Partition_Types() + { + var options = new AbpOperationRateLimitingOptions(); + + options.AddPolicy("P1", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByParameter()); + options.AddPolicy("P2", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByCurrentUser()); + options.AddPolicy("P3", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByCurrentTenant()); + options.AddPolicy("P4", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByClientIp()); + options.AddPolicy("P5", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByEmail()); + options.AddPolicy("P6", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByPhoneNumber()); + + options.Policies["P1"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); + options.Policies["P2"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.CurrentUser); + options.Policies["P3"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.CurrentTenant); + options.Policies["P4"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.ClientIp); + options.Policies["P5"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Email); + options.Policies["P6"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.PhoneNumber); + } + + [Fact] + public void Should_Throw_When_Policy_Has_No_Rules() + { + var options = new AbpOperationRateLimitingOptions(); + + var exception = Assert.Throws(() => + { + options.AddPolicy("EmptyPolicy", policy => + { + // Intentionally not adding any rules + }); + }); + + exception.Message.ShouldContain("no rules"); + } + + [Fact] + public void Should_Throw_When_WithFixedWindow_Without_PartitionBy() + { + var options = new AbpOperationRateLimitingOptions(); + + var exception = Assert.Throws(() => + { + options.AddPolicy("IncompletePolicy", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5); + // Missing PartitionBy*() call - rule never committed + }); + }); + + exception.Message.ShouldContain("no rules"); + } + + [Fact] + public void Should_Throw_When_AddRule_Without_WithFixedWindow() + { + var options = new AbpOperationRateLimitingOptions(); + + var exception = Assert.Throws(() => + { + options.AddPolicy("NoWindowPolicy", policy => + { + policy.AddRule(rule => + { + // Missing WithFixedWindow call - duration is zero + }); + }); + }); + + exception.Message.ShouldContain("positive duration"); + } + + [Fact] + public void Should_Allow_MaxCount_Zero_For_Ban_Policy() + { + var options = new AbpOperationRateLimitingOptions(); + + // maxCount=0 is a valid "ban" policy - always deny + options.AddPolicy("BanPolicy", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 0) + .PartitionByParameter(); + }); + + var policy = options.Policies["BanPolicy"]; + policy.Rules[0].MaxCount.ShouldBe(0); + } + + [Fact] + public void Should_Throw_When_AddRule_Without_PartitionBy() + { + var options = new AbpOperationRateLimitingOptions(); + + var exception = Assert.Throws(() => + { + options.AddPolicy("NoPartitionPolicy", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5)); + // Missing PartitionBy*() call + }); + }); + + exception.Message.ShouldContain("partition type"); + } + + [Fact] + public void Should_Throw_When_MaxCount_Is_Negative() + { + var options = new AbpOperationRateLimitingOptions(); + + var exception = Assert.Throws(() => + { + options.AddPolicy("NegativePolicy", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: -1) + .PartitionByParameter(); + }); + }); + + exception.Message.ShouldContain("maxCount >= 0"); + } + + [Fact] + public void Should_Allow_Same_Rule_With_Different_MultiTenancy() + { + var options = new AbpOperationRateLimitingOptions(); + + // Same Duration/MaxCount/PartitionType but different IsMultiTenant should be allowed + options.AddPolicy("MultiTenancyPolicy", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .WithMultiTenancy() + .PartitionByParameter()); + }); + + var policy = options.Policies["MultiTenancyPolicy"]; + policy.Rules.Count.ShouldBe(2); + policy.Rules[0].IsMultiTenant.ShouldBeFalse(); + policy.Rules[1].IsMultiTenant.ShouldBeTrue(); + } + + [Fact] + public void Should_Allow_Multiple_Custom_Partition_Rules() + { + var options = new AbpOperationRateLimitingOptions(); + + // Multiple custom partition rules with same Duration/MaxCount should be allowed + // because they may use different key resolvers + options.AddPolicy("MultiCustomPolicy", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .PartitionBy(ctx => Task.FromResult($"by-ip:{ctx.Parameter}"))); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .PartitionBy(ctx => Task.FromResult($"by-device:{ctx.Parameter}"))); + }); + + var policy = options.Policies["MultiCustomPolicy"]; + policy.Rules.Count.ShouldBe(2); + policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Custom); + policy.Rules[1].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Custom); + } +} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingTestBase.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingTestBase.cs new file mode 100644 index 0000000000..4316437e57 --- /dev/null +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingTestBase.cs @@ -0,0 +1,11 @@ +using Volo.Abp.Testing; + +namespace Volo.Abp.OperationRateLimiting; + +public class OperationRateLimitingTestBase : AbpIntegratedTest +{ + protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) + { + options.UseAutofac(); + } +} diff --git a/latest-versions.json b/latest-versions.json index 448a2fc3d8..0646ca8ece 100644 --- a/latest-versions.json +++ b/latest-versions.json @@ -1,4 +1,13 @@ [ + { + "version": "10.1.1", + "releaseDate": "", + "type": "stable", + "message": "", + "leptonx": { + "version": "5.1.1" + } + }, { "version": "10.1.0", "releaseDate": "", diff --git a/lowcode/schema/definitions/foreign-key-descriptor.schema.json b/lowcode/schema/definitions/foreign-key-descriptor.schema.json index 27d0e81f03..8c38f0acf7 100644 --- a/lowcode/schema/definitions/foreign-key-descriptor.schema.json +++ b/lowcode/schema/definitions/foreign-key-descriptor.schema.json @@ -20,6 +20,24 @@ "description": "Access level for managing this relation from the referenced entity side. When set to 'view' or 'edit', the referenced entity can see/manage items that reference it.", "enum": ["none", "view", "edit"], "default": "none" + }, + "dependsOn": { + "type": "object", + "description": "Cascading dependency: filter this FK's lookup by the value of another FK property on the same entity.", + "properties": { + "propertyName": { + "type": "string", + "description": "The property name on the owning entity whose value provides the filter (e.g. 'CountryId' on Author)", + "minLength": 1 + }, + "filterPropertyName": { + "type": "string", + "description": "The property name on the target (lookup) entity to filter by (e.g. 'CountryId' on City)", + "minLength": 1 + } + }, + "required": ["propertyName", "filterPropertyName"], + "additionalProperties": false } }, "required": ["entityName"], diff --git a/lowcode/schema/definitions/form-descriptor.schema.json b/lowcode/schema/definitions/form-descriptor.schema.json new file mode 100644 index 0000000000..60a0b14f0e --- /dev/null +++ b/lowcode/schema/definitions/form-descriptor.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "form-descriptor.schema.json", + "title": "FormDescriptor", + "description": "Describes a named form definition bound to an entity", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the form", + "minLength": 1 + }, + "entityName": { + "type": "string", + "description": "Full name of the entity this form is bound to (e.g., 'Namespace.EntityName')", + "minLength": 1 + }, + "fields": { + "type": "array", + "description": "Flat list of all field definitions in this form", + "items": { + "$ref": "form-field-descriptor.schema.json" + } + }, + "layout": { + "$ref": "form-layout-descriptor.schema.json" + }, + "rules": { + "type": "array", + "description": "Conditional rules for field/group visibility and enabled state", + "items": { + "$ref": "form-rule-descriptor.schema.json" + } + } + }, + "required": ["name", "entityName", "fields", "layout"], + "additionalProperties": false +} diff --git a/lowcode/schema/definitions/form-field-descriptor.schema.json b/lowcode/schema/definitions/form-field-descriptor.schema.json new file mode 100644 index 0000000000..b5334cc583 --- /dev/null +++ b/lowcode/schema/definitions/form-field-descriptor.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "form-field-descriptor.schema.json", + "title": "FormFieldDescriptor", + "description": "Describes a single field in a form", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this field within the form", + "minLength": 1 + }, + "label": { + "type": "string", + "description": "Display label for the field", + "minLength": 1 + }, + "type": { + "$ref": "form-field-type.schema.json" + }, + "binding": { + "type": ["string", "null"], + "description": "Entity property name to bind to, or null for unbound fields. Supports dotted paths like 'Parent.Name' for related entity display." + }, + "enumType": { + "type": "string", + "description": "Full enum type name for select fields" + }, + "defaultValue": { + "description": "Default value for the field" + }, + "placeholder": { + "type": "string", + "description": "Placeholder text for the input" + }, + "helpText": { + "type": "string", + "description": "Help text displayed below the field" + }, + "readOnly": { + "type": "boolean", + "description": "Whether the field is read-only", + "default": false + }, + "modeVisibility": { + "type": "string", + "enum": ["both", "createOnly", "editOnly"], + "description": "Controls in which form mode the field is visible", + "default": "both" + }, + "validations": { + "type": "array", + "description": "Form-level validation rules (override or extend entity-level validators)", + "items": { + "$ref": "validator-descriptor.schema.json" + } + } + }, + "required": ["id", "label", "type"], + "additionalProperties": false +} diff --git a/lowcode/schema/definitions/form-field-type.schema.json b/lowcode/schema/definitions/form-field-type.schema.json new file mode 100644 index 0000000000..03af7d0117 --- /dev/null +++ b/lowcode/schema/definitions/form-field-type.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "form-field-type.schema.json", + "title": "FormFieldType", + "description": "Available field types for form fields", + "type": "string", + "enum": [ + "text", + "textarea", + "number", + "checkbox", + "date", + "select", + "lookup", + "guid", + "computed" + ] +} diff --git a/lowcode/schema/definitions/form-layout-descriptor.schema.json b/lowcode/schema/definitions/form-layout-descriptor.schema.json new file mode 100644 index 0000000000..74457b0ed6 --- /dev/null +++ b/lowcode/schema/definitions/form-layout-descriptor.schema.json @@ -0,0 +1,105 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "form-layout-descriptor.schema.json", + "title": "FormLayoutDescriptor", + "description": "Describes the visual layout of a form (tabs > groups > field placements)", + "type": "object", + "properties": { + "tabs": { + "type": "array", + "description": "Ordered list of tabs in the form", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this tab", + "minLength": 1 + }, + "title": { + "type": "string", + "description": "Display title for the tab", + "minLength": 1 + }, + "isDefault": { + "type": "boolean", + "description": "Whether this is the default tab (cannot be deleted, receives orphaned fields)", + "default": false + }, + "groups": { + "type": "array", + "description": "Ordered list of groups within this tab", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this group", + "minLength": 1 + }, + "title": { + "type": ["string", "null"], + "description": "Optional display title for the group" + }, + "isDefault": { + "type": "boolean", + "description": "Whether this is the default group (cannot be deleted, receives orphaned fields)", + "default": false + }, + "rows": { + "type": "array", + "description": "Ordered list of layout rows; each row contains one or more cells (fields placed side-by-side)", + "items": { + "type": "object", + "properties": { + "cells": { + "type": "array", + "description": "Fields placed side-by-side in this row (total colSpan should not exceed 4)", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "fieldId": { + "type": "string", + "description": "Reference to a field id in the form's fields array", + "minLength": 1 + }, + "colSpan": { + "type": "integer", + "description": "Number of grid columns this field spans (1-4)", + "minimum": 1, + "maximum": 4, + "default": 4 + }, + "colStart": { + "type": "integer", + "description": "Starting grid column (1-4). Omit or null to auto-place after the previous cell.", + "minimum": 1, + "maximum": 4 + } + }, + "required": ["fieldId"], + "additionalProperties": false + } + } + }, + "required": ["cells"], + "additionalProperties": false + } + } + }, + "required": ["id", "rows"], + "additionalProperties": false + } + } + }, + "required": ["id", "title", "groups"], + "additionalProperties": false + } + } + }, + "required": ["tabs"], + "additionalProperties": false +} diff --git a/lowcode/schema/definitions/form-rule-descriptor.schema.json b/lowcode/schema/definitions/form-rule-descriptor.schema.json new file mode 100644 index 0000000000..aeee227991 --- /dev/null +++ b/lowcode/schema/definitions/form-rule-descriptor.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "form-rule-descriptor.schema.json", + "title": "FormRuleDescriptor", + "description": "Describes a conditional rule with one or more actions that execute when the condition is met", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this rule", + "minLength": 1 + }, + "name": { + "type": "string", + "description": "Human-readable name for this rule (optional)" + }, + "condition": { + "type": "object", + "description": "The condition that triggers this rule", + "properties": { + "fieldId": { + "type": "string", + "description": "The field whose value is evaluated", + "minLength": 1 + }, + "operator": { + "type": "string", + "enum": ["equals", "notEquals", "isEmpty", "isNotEmpty"], + "description": "Comparison operator" + }, + "value": { + "description": "The value to compare against (not used for isEmpty/isNotEmpty)" + } + }, + "required": ["fieldId", "operator"], + "additionalProperties": false + }, + "actions": { + "type": "array", + "description": "Actions to perform when condition is met (executed in order)", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["hide", "show", "disable", "enable", "setValue"], + "description": "The action type" + }, + "targetType": { + "type": "string", + "enum": ["field", "group"], + "description": "Whether the target is a field or a group" + }, + "targetId": { + "type": "string", + "description": "The id of the target field or group", + "minLength": 1 + }, + "value": { + "description": "The value to set (only for setValue action)" + } + }, + "required": ["type", "targetType", "targetId"], + "additionalProperties": false + } + } + }, + "required": ["id", "condition", "actions"], + "additionalProperties": false +} diff --git a/lowcode/schema/definitions/page-descriptor.schema.json b/lowcode/schema/definitions/page-descriptor.schema.json new file mode 100644 index 0000000000..b328dbe073 --- /dev/null +++ b/lowcode/schema/definitions/page-descriptor.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "page-descriptor.schema.json", + "title": "PageDescriptor", + "description": "Describes a UI page bound to an entity", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique URL-safe identifier for the page", + "minLength": 1 + }, + "title": { + "type": "string", + "description": "Display title for the menu item and page header", + "minLength": 1 + }, + "icon": { + "type": "string", + "description": "FontAwesome icon class (e.g., 'fa-solid fa-users')" + }, + "type": { + "$ref": "page-type.schema.json" + }, + "entityName": { + "type": "string", + "description": "Full name of the root entity this page displays (e.g., 'Namespace.EntityName')", + "minLength": 1 + }, + "groupByProperty": { + "type": "string", + "description": "Property name to group entities by (required for kanban). Must reference an enum property.", + "minLength": 1 + }, + "order": { + "type": "integer", + "description": "Menu sort order (lower values appear first)", + "default": 0 + }, + "formName": { + "type": "string", + "description": "Name of the form to render (required when type is 'form')", + "minLength": 1 + }, + "createFormName": { + "type": "string", + "description": "Name of the form to use for creating entities (for dataGrid/kanban pages)" + }, + "editFormName": { + "type": "string", + "description": "Name of the form to use for editing entities (for dataGrid/kanban pages)" + }, + "createFormDisplay": { + "type": "string", + "enum": ["modal", "page"], + "description": "How to display the create form", + "default": "modal" + }, + "editFormDisplay": { + "type": "string", + "enum": ["modal", "page"], + "description": "How to display the edit form", + "default": "modal" + } + }, + "required": ["name", "title", "type", "entityName"], + "allOf": [ + { + "if": { + "properties": { "type": { "const": "kanban" } } + }, + "then": { + "required": ["groupByProperty"] + } + }, + { + "if": { + "properties": { "type": { "const": "form" } } + }, + "then": { + "required": ["formName"] + } + } + ], + "additionalProperties": false +} diff --git a/lowcode/schema/definitions/page-type.schema.json b/lowcode/schema/definitions/page-type.schema.json new file mode 100644 index 0000000000..8a185619fe --- /dev/null +++ b/lowcode/schema/definitions/page-type.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "page-type.schema.json", + "title": "PageType", + "description": "The type of page to render", + "type": "string", + "enum": ["dataGrid", "kanban", "calendar", "form"] +} diff --git a/lowcode/schema/model.schema.json b/lowcode/schema/model.schema.json index c958f126fe..2315475939 100644 --- a/lowcode/schema/model.schema.json +++ b/lowcode/schema/model.schema.json @@ -29,6 +29,20 @@ "items": { "$ref": "definitions/endpoint-descriptor.schema.json" } + }, + "pages": { + "type": "array", + "description": "List of UI pages that appear as menu items and define how entities are displayed", + "items": { + "$ref": "definitions/page-descriptor.schema.json" + } + }, + "forms": { + "type": "array", + "description": "List of named form definitions for entity create/edit", + "items": { + "$ref": "definitions/form-descriptor.schema.json" + } } }, "additionalProperties": false diff --git a/modules/docs/app/VoloDocs.EntityFrameworkCore/Migrations/20260227074745_ABP10_2.Designer.cs b/modules/docs/app/VoloDocs.EntityFrameworkCore/Migrations/20260227074745_ABP10_2.Designer.cs new file mode 100644 index 0000000000..b95e114e8c --- /dev/null +++ b/modules/docs/app/VoloDocs.EntityFrameworkCore/Migrations/20260227074745_ABP10_2.Designer.cs @@ -0,0 +1,1538 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Volo.Abp.EntityFrameworkCore; +using VoloDocs.EntityFrameworkCore; + +#nullable disable + +namespace Migrations +{ + [DbContext(typeof(VoloDocsDbContext))] + [Migration("20260227074745_ABP10_2")] + partial class ABP10_2 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.SqlServer) + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Volo.Abp.BlobStoring.Database.DatabaseBlob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContainerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .HasMaxLength(2147483647) + .HasColumnType("varbinary(max)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("ExtraProperties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ContainerId"); + + b.HasIndex("TenantId", "ContainerId", "Name"); + + b.ToTable("AbpBlobs", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.BlobStoring.Database.DatabaseBlobContainer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("ExtraProperties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name"); + + b.ToTable("AbpBlobContainers", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityClaimType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("ExtraProperties"); + + b.Property("IsStatic") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Regex") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RegexDescription") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Required") + .HasColumnType("bit"); + + b.Property("ValueType") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("AbpClaimTypes", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityLinkUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("SourceTenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("SourceUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("TargetTenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("TargetUserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SourceUserId", "SourceTenantId", "TargetUserId", "TargetTenantId") + .IsUnique() + .HasFilter("[SourceTenantId] IS NOT NULL AND [TargetTenantId] IS NOT NULL"); + + b.ToTable("AbpLinkUsers", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("EntityVersion") + .HasColumnType("int"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("ExtraProperties"); + + b.Property("IsDefault") + .HasColumnType("bit") + .HasColumnName("IsDefault"); + + b.Property("IsPublic") + .HasColumnType("bit") + .HasColumnName("IsPublic"); + + b.Property("IsStatic") + .HasColumnType("bit") + .HasColumnName("IsStatic"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName"); + + b.ToTable("AbpRoles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("ClaimType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClaimValue") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AbpRoleClaims", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentitySecurityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .HasMaxLength(96) + .HasColumnType("nvarchar(96)"); + + b.Property("ApplicationName") + .HasMaxLength(96) + .HasColumnType("nvarchar(96)"); + + b.Property("BrowserInfo") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ClientId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreationTime") + .HasColumnType("datetime2"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("ExtraProperties"); + + b.Property("Identity") + .HasMaxLength(96) + .HasColumnType("nvarchar(96)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("TenantName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Action"); + + b.HasIndex("TenantId", "ApplicationName"); + + b.HasIndex("TenantId", "Identity"); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("AbpSecurityLogs", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentitySession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Device") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeviceInfo") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExtraProperties") + .HasColumnType("nvarchar(max)") + .HasColumnName("ExtraProperties"); + + b.Property("IpAddresses") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("LastAccessed") + .HasColumnType("datetime2"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SignedIn") + .HasColumnType("datetime2"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Device"); + + b.HasIndex("SessionId"); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("AbpSessions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("AccessFailedCount"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uniqueidentifier") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("datetime2") + .HasColumnName("DeletionTime"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("Email"); + + b.Property("EmailConfirmed") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("EmailConfirmed"); + + b.Property("EntityVersion") + .HasColumnType("int"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("IsActive"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsExternal") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsExternal"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifierId"); + + b.Property("LastPasswordChangeTime") + .HasColumnType("datetimeoffset"); + + b.Property("LastSignInTime") + .HasColumnType("datetimeoffset"); + + b.Property("Leaved") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("Leaved"); + + b.Property("LockoutEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("LockoutEnabled"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("Name"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("NormalizedEmail"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("NormalizedUserName"); + + b.Property("PasswordHash") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("PasswordHash"); + + b.Property("PhoneNumber") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("PhoneNumber"); + + b.Property("PhoneNumberConfirmed") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("PhoneNumberConfirmed"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("SecurityStamp"); + + b.Property("ShouldChangePasswordOnNextLogin") + .HasColumnType("bit"); + + b.Property("Surname") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("Surname"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("TwoFactorEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("TwoFactorEnabled"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("UserName"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("NormalizedEmail"); + + b.HasIndex("NormalizedUserName"); + + b.HasIndex("UserName"); + + b.ToTable("AbpUsers", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("ClaimType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClaimValue") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AbpUserClaims", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserDelegation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EndTime") + .HasColumnType("datetime2"); + + b.Property("SourceUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartTime") + .HasColumnType("datetime2"); + + b.Property("TargetUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("AbpUserDelegations", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserLogin", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ProviderDisplayName") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(196) + .HasColumnType("nvarchar(196)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("UserId", "LoginProvider"); + + b.HasIndex("LoginProvider", "ProviderKey"); + + b.ToTable("AbpUserLogins", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserOrganizationUnit", b => + { + b.Property("OrganizationUnitId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatorId"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("OrganizationUnitId", "UserId"); + + b.HasIndex("UserId", "OrganizationUnitId"); + + b.ToTable("AbpUserOrganizationUnits", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .HasMaxLength(1024) + .HasColumnType("varbinary(1024)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AbpUserPasskeys", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserPasswordHistory", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Password") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("UserId", "Password"); + + b.ToTable("AbpUserPasswordHistories", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId", "UserId"); + + b.ToTable("AbpUserRoles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AbpUserTokens", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(95) + .HasColumnType("nvarchar(95)") + .HasColumnName("Code"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uniqueidentifier") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("datetime2") + .HasColumnName("DeletionTime"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("DisplayName"); + + b.Property("EntityVersion") + .HasColumnType("int"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifierId"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("ParentId"); + + b.ToTable("AbpOrganizationUnits", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnitRole", b => + { + b.Property("OrganizationUnitId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatorId"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("OrganizationUnitId", "RoleId"); + + b.HasIndex("RoleId", "OrganizationUnitId"); + + b.ToTable("AbpOrganizationUnitRoles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExtraProperties") + .HasColumnType("nvarchar(max)") + .HasColumnName("ExtraProperties"); + + b.Property("GroupName") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("ManagementPermissionName") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("MultiTenancySide") + .HasColumnType("tinyint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParentName") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Providers") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResourceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("StateCheckers") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("GroupName"); + + b.HasIndex("ResourceName", "Name") + .IsUnique() + .HasFilter("[ResourceName] IS NOT NULL"); + + b.ToTable("AbpPermissions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name", "ProviderName", "ProviderKey") + .IsUnique() + .HasFilter("[TenantId] IS NOT NULL"); + + b.ToTable("AbpPermissionGrants", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionGroupDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExtraProperties") + .HasColumnType("nvarchar(max)") + .HasColumnName("ExtraProperties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AbpPermissionGroups", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.ResourcePermissionGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ResourceKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ResourceName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name", "ResourceName", "ResourceKey", "ProviderName", "ProviderKey") + .IsUnique() + .HasFilter("[TenantId] IS NOT NULL"); + + b.ToTable("AbpResourcePermissionGrants", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.SettingManagement.Setting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ProviderName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.HasIndex("Name", "ProviderName", "ProviderKey") + .IsUnique() + .HasFilter("[ProviderName] IS NOT NULL AND [ProviderKey] IS NOT NULL"); + + b.ToTable("AbpSettings", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.SettingManagement.SettingDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DefaultValue") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExtraProperties") + .HasColumnType("nvarchar(max)") + .HasColumnName("ExtraProperties"); + + b.Property("IsEncrypted") + .HasColumnType("bit"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsVisibleToClients") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Providers") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AbpSettingDefinitions", (string)null); + }); + + modelBuilder.Entity("Volo.Docs.Documents.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreationTime") + .HasColumnType("datetime2"); + + b.Property("EditLink") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Format") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LanguageCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LastCachedTime") + .HasColumnType("datetime2"); + + b.Property("LastSignificantUpdateTime") + .HasColumnType("datetime2"); + + b.Property("LastUpdatedTime") + .HasColumnType("datetime2"); + + b.Property("LocalDirectory") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b.Property("RawRootUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RootUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.ToTable("DocsDocuments", (string)null); + }); + + modelBuilder.Entity("Volo.Docs.Documents.DocumentContributor", b => + { + b.Property("DocumentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Username") + .HasColumnType("nvarchar(450)"); + + b.Property("AvatarUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("CommitCount") + .HasColumnType("int"); + + b.Property("UserProfileUrl") + .HasColumnType("nvarchar(max)"); + + b.HasKey("DocumentId", "Username"); + + b.ToTable("DocsDocumentContributors", (string)null); + }); + + modelBuilder.Entity("Volo.Docs.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("DefaultDocumentName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DocumentStoreType") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("ExtraProperties"); + + b.Property("Format") + .HasColumnType("nvarchar(max)"); + + b.Property("LatestVersionBranchName") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("MainWebsiteUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MinimumVersion") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NavigationDocumentName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParametersDocumentName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ShortName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.ToTable("DocsProjects", (string)null); + }); + + modelBuilder.Entity("Volo.Docs.Projects.ProjectPdfFile", b => + { + b.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b.Property("FileName") + .HasColumnType("nvarchar(450)"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("LanguageCode") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2") + .HasColumnName("LastModificationTime"); + + b.Property("Version") + .HasColumnType("nvarchar(max)"); + + b.HasKey("ProjectId", "FileName"); + + b.ToTable("DocsProjectPdfFiles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.BlobStoring.Database.DatabaseBlob", b => + { + b.HasOne("Volo.Abp.BlobStoring.Database.DatabaseBlobContainer", null) + .WithMany() + .HasForeignKey("ContainerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRoleClaim", b => + { + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserClaim", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserLogin", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserOrganizationUnit", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany() + .HasForeignKey("OrganizationUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("OrganizationUnits") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserPasskey", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Passkeys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Volo.Abp.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("IdentityUserPasskeyCredentialId"); + + b1.Property("AttestationObject"); + + b1.Property("ClientDataJson"); + + b1.Property("CreatedAt"); + + b1.Property("IsBackedUp"); + + b1.Property("IsBackupEligible"); + + b1.Property("IsUserVerified"); + + b1.Property("Name"); + + b1.Property("PublicKey"); + + b1.Property("SignCount"); + + b1.PrimitiveCollection("Transports"); + + b1.HasKey("IdentityUserPasskeyCredentialId"); + + b1.ToTable("AbpUserPasskeys"); + + b1 + .ToJson("Data") + .HasColumnType("nvarchar(max)"); + + b1.WithOwner() + .HasForeignKey("IdentityUserPasskeyCredentialId"); + }); + + b.Navigation("Data"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserPasswordHistory", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("PasswordHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserRole", b => + { + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserToken", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany() + .HasForeignKey("ParentId"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnitRole", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany("Roles") + .HasForeignKey("OrganizationUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Docs.Documents.DocumentContributor", b => + { + b.HasOne("Volo.Docs.Documents.Document", null) + .WithMany("Contributors") + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Docs.Projects.ProjectPdfFile", b => + { + b.HasOne("Volo.Docs.Projects.Project", null) + .WithMany("PdfFiles") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRole", b => + { + b.Navigation("Claims"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUser", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("OrganizationUnits"); + + b.Navigation("Passkeys"); + + b.Navigation("PasswordHistories"); + + b.Navigation("Roles"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.Navigation("Roles"); + }); + + modelBuilder.Entity("Volo.Docs.Documents.Document", b => + { + b.Navigation("Contributors"); + }); + + modelBuilder.Entity("Volo.Docs.Projects.Project", b => + { + b.Navigation("PdfFiles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/modules/docs/app/VoloDocs.EntityFrameworkCore/Migrations/20260227074745_ABP10_2.cs b/modules/docs/app/VoloDocs.EntityFrameworkCore/Migrations/20260227074745_ABP10_2.cs new file mode 100644 index 0000000000..b13305aa72 --- /dev/null +++ b/modules/docs/app/VoloDocs.EntityFrameworkCore/Migrations/20260227074745_ABP10_2.cs @@ -0,0 +1,160 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Migrations +{ + /// + public partial class ABP10_2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_AbpPermissions_Name", + table: "AbpPermissions"); + + migrationBuilder.AddColumn( + name: "LastSignInTime", + table: "AbpUsers", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "Leaved", + table: "AbpUsers", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AlterColumn( + name: "GroupName", + table: "AbpPermissions", + type: "nvarchar(128)", + maxLength: 128, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(128)", + oldMaxLength: 128); + + migrationBuilder.AddColumn( + name: "ManagementPermissionName", + table: "AbpPermissions", + type: "nvarchar(128)", + maxLength: 128, + nullable: true); + + migrationBuilder.AddColumn( + name: "ResourceName", + table: "AbpPermissions", + type: "nvarchar(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.CreateTable( + name: "AbpResourcePermissionGrants", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: true), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ProviderName = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ProviderKey = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ResourceName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + ResourceKey = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AbpResourcePermissionGrants", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AbpUserPasskeys", + columns: table => new + { + CredentialId = table.Column(type: "varbinary(1024)", maxLength: 1024, nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: true), + UserId = table.Column(type: "uniqueidentifier", nullable: false), + Data = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AbpUserPasskeys", x => x.CredentialId); + table.ForeignKey( + name: "FK_AbpUserPasskeys_AbpUsers_UserId", + column: x => x.UserId, + principalTable: "AbpUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AbpPermissions_ResourceName_Name", + table: "AbpPermissions", + columns: new[] { "ResourceName", "Name" }, + unique: true, + filter: "[ResourceName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AbpResourcePermissionGrants_TenantId_Name_ResourceName_ResourceKey_ProviderName_ProviderKey", + table: "AbpResourcePermissionGrants", + columns: new[] { "TenantId", "Name", "ResourceName", "ResourceKey", "ProviderName", "ProviderKey" }, + unique: true, + filter: "[TenantId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AbpUserPasskeys_UserId", + table: "AbpUserPasskeys", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AbpResourcePermissionGrants"); + + migrationBuilder.DropTable( + name: "AbpUserPasskeys"); + + migrationBuilder.DropIndex( + name: "IX_AbpPermissions_ResourceName_Name", + table: "AbpPermissions"); + + migrationBuilder.DropColumn( + name: "LastSignInTime", + table: "AbpUsers"); + + migrationBuilder.DropColumn( + name: "Leaved", + table: "AbpUsers"); + + migrationBuilder.DropColumn( + name: "ManagementPermissionName", + table: "AbpPermissions"); + + migrationBuilder.DropColumn( + name: "ResourceName", + table: "AbpPermissions"); + + migrationBuilder.AlterColumn( + name: "GroupName", + table: "AbpPermissions", + type: "nvarchar(128)", + maxLength: 128, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "nvarchar(128)", + oldMaxLength: 128, + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_AbpPermissions_Name", + table: "AbpPermissions", + column: "Name", + unique: true); + } + } +} diff --git a/modules/docs/app/VoloDocs.EntityFrameworkCore/Migrations/VoloDocsDbContextModelSnapshot.cs b/modules/docs/app/VoloDocs.EntityFrameworkCore/Migrations/VoloDocsDbContextModelSnapshot.cs index b10ce9d10e..05bd51c9c1 100644 --- a/modules/docs/app/VoloDocs.EntityFrameworkCore/Migrations/VoloDocsDbContextModelSnapshot.cs +++ b/modules/docs/app/VoloDocs.EntityFrameworkCore/Migrations/VoloDocsDbContextModelSnapshot.cs @@ -19,7 +19,7 @@ namespace Migrations #pragma warning disable 612, 618 modelBuilder .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.SqlServer) - .HasAnnotation("ProductVersion", "10.0.0-rc.2.25502.107") + .HasAnnotation("ProductVersion", "10.0.2") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -482,6 +482,15 @@ namespace Migrations b.Property("LastPasswordChangeTime") .HasColumnType("datetimeoffset"); + b.Property("LastSignInTime") + .HasColumnType("datetimeoffset"); + + b.Property("Leaved") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("Leaved"); + b.Property("LockoutEnabled") .ValueGeneratedOnAdd() .HasColumnType("bit") @@ -678,6 +687,26 @@ namespace Migrations b.ToTable("AbpUserOrganizationUnits", (string)null); }); + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .HasMaxLength(1024) + .HasColumnType("varbinary(1024)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AbpUserPasskeys", (string)null); + }); + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserPasswordHistory", b => { b.Property("UserId") @@ -865,13 +894,16 @@ namespace Migrations .HasColumnName("ExtraProperties"); b.Property("GroupName") - .IsRequired() .HasMaxLength(128) .HasColumnType("nvarchar(128)"); b.Property("IsEnabled") .HasColumnType("bit"); + b.Property("ManagementPermissionName") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + b.Property("MultiTenancySide") .HasColumnType("tinyint"); @@ -888,6 +920,10 @@ namespace Migrations .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("ResourceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + b.Property("StateCheckers") .HasMaxLength(256) .HasColumnType("nvarchar(256)"); @@ -896,8 +932,9 @@ namespace Migrations b.HasIndex("GroupName"); - b.HasIndex("Name") - .IsUnique(); + b.HasIndex("ResourceName", "Name") + .IsUnique() + .HasFilter("[ResourceName] IS NOT NULL"); b.ToTable("AbpPermissions", (string)null); }); @@ -964,6 +1001,50 @@ namespace Migrations b.ToTable("AbpPermissionGroups", (string)null); }); + modelBuilder.Entity("Volo.Abp.PermissionManagement.ResourcePermissionGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ResourceKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ResourceName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name", "ResourceName", "ResourceKey", "ProviderName", "ProviderKey") + .IsUnique() + .HasFilter("[TenantId] IS NOT NULL"); + + b.ToTable("AbpResourcePermissionGrants", (string)null); + }); + modelBuilder.Entity("Volo.Abp.SettingManagement.Setting", b => { b.Property("Id") @@ -1292,6 +1373,53 @@ namespace Migrations .IsRequired(); }); + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserPasskey", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Passkeys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Volo.Abp.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("IdentityUserPasskeyCredentialId"); + + b1.Property("AttestationObject"); + + b1.Property("ClientDataJson"); + + b1.Property("CreatedAt"); + + b1.Property("IsBackedUp"); + + b1.Property("IsBackupEligible"); + + b1.Property("IsUserVerified"); + + b1.Property("Name"); + + b1.Property("PublicKey"); + + b1.Property("SignCount"); + + b1.PrimitiveCollection("Transports"); + + b1.HasKey("IdentityUserPasskeyCredentialId"); + + b1.ToTable("AbpUserPasskeys"); + + b1 + .ToJson("Data") + .HasColumnType("nvarchar(max)"); + + b1.WithOwner() + .HasForeignKey("IdentityUserPasskeyCredentialId"); + }); + + b.Navigation("Data"); + }); + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserPasswordHistory", b => { b.HasOne("Volo.Abp.Identity.IdentityUser", null) @@ -1378,6 +1506,8 @@ namespace Migrations b.Navigation("OrganizationUnits"); + b.Navigation("Passkeys"); + b.Navigation("PasswordHistories"); b.Navigation("Roles"); diff --git a/modules/docs/app/VoloDocs.Migrator/appsettings.json b/modules/docs/app/VoloDocs.Migrator/appsettings.json index 9668adc1e2..aa1a142134 100644 --- a/modules/docs/app/VoloDocs.Migrator/appsettings.json +++ b/modules/docs/app/VoloDocs.Migrator/appsettings.json @@ -1,3 +1,3 @@ { - "ConnectionString": "Server=localhost;Database=VoloDocs;Trusted_Connection=True;TrustServerCertificate=True", + "ConnectionString": "Server=(LocalDb)\\MSSQLLocalDB;Database=VoloDocs;Trusted_Connection=True;TrustServerCertificate=True" } \ No newline at end of file diff --git a/modules/docs/app/VoloDocs.Web/VoloDocs.Web.abppkg b/modules/docs/app/VoloDocs.Web/VoloDocs.Web.abppkg index 48875c29cf..9f088dc93e 100644 --- a/modules/docs/app/VoloDocs.Web/VoloDocs.Web.abppkg +++ b/modules/docs/app/VoloDocs.Web/VoloDocs.Web.abppkg @@ -1,3 +1,4 @@ { - "role": "host.mvc" + "role": "host.mvc", + "projectId": "47142bf8-4bb8-41c5-9900-990a97a67e8a" } \ No newline at end of file diff --git a/modules/docs/app/VoloDocs.Web/appsettings.json b/modules/docs/app/VoloDocs.Web/appsettings.json index 75d1b9890e..586d1d1a8a 100644 --- a/modules/docs/app/VoloDocs.Web/appsettings.json +++ b/modules/docs/app/VoloDocs.Web/appsettings.json @@ -12,7 +12,7 @@ "Redis": { "Configuration": "127.0.0.1" }, - "ConnectionString": "Server=localhost;Database=VoloDocs;Trusted_Connection=True;TrustServerCertificate=True", + "ConnectionString": "Server=(LocalDb)\\MSSQLLocalDB;Database=VoloDocs;Trusted_Connection=True;TrustServerCertificate=True", "ElasticSearch": { "Url": "http://localhost:9200" }, diff --git a/nupkg/common.ps1 b/nupkg/common.ps1 index 6fbc34e80c..cb356faee4 100644 --- a/nupkg/common.ps1 +++ b/nupkg/common.ps1 @@ -237,6 +237,7 @@ $projects = ( "framework/src/Volo.Abp.Minify", "framework/src/Volo.Abp.ObjectExtending", "framework/src/Volo.Abp.ObjectMapping", + "framework/src/Volo.Abp.OperationRateLimiting", "framework/src/Volo.Abp.Quartz", "framework/src/Volo.Abp.RabbitMQ", "framework/src/Volo.Abp.RemoteServices",