Browse Source

Merge branch 'dev' into issue-24918-bg-jobs

pull/25059/head
maliming 7 months ago
parent
commit
89ce56e055
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 220
      .claude/skills/abp-angular/SKILL.md
  2. 78
      .claude/skills/abp-app-nolayers/SKILL.md
  3. 239
      .claude/skills/abp-application-layer/SKILL.md
  4. 182
      .claude/skills/abp-authorization/SKILL.md
  5. 206
      .claude/skills/abp-blazor/SKILL.md
  6. 89
      .claude/skills/abp-cli/SKILL.md
  7. 190
      .claude/skills/abp-core/SKILL.md
  8. 248
      .claude/skills/abp-ddd/SKILL.md
  9. 150
      .claude/skills/abp-dependency-rules/SKILL.md
  10. 261
      .claude/skills/abp-development-flow/SKILL.md
  11. 262
      .claude/skills/abp-ef-core/SKILL.md
  12. 243
      .claude/skills/abp-infrastructure/SKILL.md
  13. 209
      .claude/skills/abp-microservice/SKILL.md
  14. 234
      .claude/skills/abp-module/SKILL.md
  15. 202
      .claude/skills/abp-mongodb/SKILL.md
  16. 161
      .claude/skills/abp-multi-tenancy/SKILL.md
  17. 257
      .claude/skills/abp-mvc/SKILL.md
  18. 269
      .claude/skills/abp-testing/SKILL.md
  19. 87
      .github/scripts/test_update_dependency_changes.py
  20. 21
      .github/scripts/update_dependency_changes.py
  21. 1
      .gitignore
  22. 28
      Directory.Packages.props
  23. 314
      docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md
  24. BIN
      docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/cover.jpeg
  25. 113
      docs/en/Community-Articles/2026-03-10-Tutorial-Validator/article.md
  26. BIN
      docs/en/Community-Articles/2026-03-10-Tutorial-Validator/docs/images/image-1.png
  27. BIN
      docs/en/Community-Articles/2026-03-10-Tutorial-Validator/docs/images/image-2.png
  28. BIN
      docs/en/Community-Articles/2026-03-10-Tutorial-Validator/docs/images/image.png
  29. 185
      docs/en/Community-Articles/2026-03-12-OpenIddict-private-key-jwt/POST.md
  30. BIN
      docs/en/Community-Articles/2026-03-12-OpenIddict-private-key-jwt/cover.png
  31. BIN
      docs/en/Community-Articles/2026-03-12-OpenIddict-private-key-jwt/create-edit-ui.png
  32. 151
      docs/en/Community-Articles/2026-03-17-OpenAI-Compatible-Endpoints/POST.md
  33. BIN
      docs/en/Community-Articles/2026-03-17-OpenAI-Compatible-Endpoints/cover-image.png
  34. BIN
      docs/en/Community-Articles/2026-03-17-OpenAI-Compatible-Endpoints/openai-compatible-endpoints-demo.gif
  35. 167
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/POST.md
  36. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/cover.png
  37. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/exist-user-accept.png
  38. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/invite-admin-user-to-join-tenant-modal.png
  39. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/invite-admin-user-to-join-tenant.png
  40. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/invite-user.png
  41. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/new-user-accept.png
  42. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/new-user-join-strategy-create-tenant.png
  43. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/new-user-join-strategy-inform.png
  44. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/switch-tenant.png
  45. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/tenant-selection.png
  46. 94
      docs/en/cli/index.md
  47. 168
      docs/en/contribution/angular-ui.md
  48. 46
      docs/en/docs-nav.json
  49. 4
      docs/en/framework/api-development/auto-controllers.md
  50. 4
      docs/en/framework/infrastructure/background-jobs/tickerq.md
  51. 8
      docs/en/framework/infrastructure/background-workers/tickerq.md
  52. 113
      docs/en/framework/infrastructure/entity-cache.md
  53. 82
      docs/en/framework/ui/angular/extensions-overall.md
  54. 20
      docs/en/framework/ui/maui/index.md
  55. BIN
      docs/en/images/adb-command-prompt.png
  56. 79
      docs/en/modules/ai-management/index.md
  57. 289
      docs/en/modules/operation-rate-limiting.md
  58. 31
      docs/en/package-version-changes.md
  59. 2
      framework/Volo.Abp.slnx
  60. 5
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/AbpBlazorClientHttpMessageHandler.cs
  61. 11
      framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpInputTagHelperService.cs
  62. 7
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController.cs
  63. 22
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/IXmlDocumentationProvider.cs
  64. 231
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs
  65. 224
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs
  66. 7
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ProxyScripting/AbpServiceProxyScriptController.cs
  67. 59
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Validation/AbpValidationActionFilter.cs
  68. 4
      framework/src/Volo.Abp.BackgroundJobs.RabbitMQ/Volo/Abp/BackgroundJobs/RabbitMQ/JobQueue.cs
  69. 18
      framework/src/Volo.Abp.BackgroundJobs.TickerQ/Volo/Abp/BackgroundJobs/TickerQ/AbpBackgroundJobsTickerQModule.cs
  70. 2
      framework/src/Volo.Abp.BackgroundJobs.TickerQ/Volo/Abp/BackgroundJobs/TickerQ/AbpBackgroundJobsTimeTickerConfiguration.cs
  71. 2
      framework/src/Volo.Abp.BackgroundWorkers.TickerQ/Volo/Abp/BackgroundWorkers/TickerQ/AbpBackgroundWorkersCronTickerConfiguration.cs
  72. 4
      framework/src/Volo.Abp.BackgroundWorkers.TickerQ/Volo/Abp/BackgroundWorkers/TickerQ/AbpTickerQBackgroundWorkerManager.cs
  73. 1
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/AbpCliCoreModule.cs
  74. 173
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/GenerateJwksCommand.cs
  75. 88
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheBase.cs
  76. 30
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheServiceCollectionExtensions.cs
  77. 10
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapper.cs
  78. 1
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapperContext.cs
  79. 1
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithoutCacheItem.cs
  80. 38
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/IEntityCache.cs
  81. 8
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs
  82. 2
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ApplicationApiDescriptionModelRequestDto.cs
  83. 16
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ControllerApiDescriptionModel.cs
  84. 4
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IApiDescriptionModelProvider.cs
  85. 6
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/MethodParameterApiDescriptionModel.cs
  86. 6
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ParameterApiDescriptionModel.cs
  87. 6
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs
  88. 2
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs
  89. 8
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs
  90. 4
      framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManager.cs
  91. 7
      framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManagerCache.cs
  92. 13
      framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManager.cs
  93. 31
      framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManagerCache.cs
  94. 3
      framework/src/Volo.Abp.OperationRateLimiting/FodyWeavers.xml
  95. 32
      framework/src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj
  96. 14
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs
  97. 42
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingModule.cs
  98. 20
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs
  99. 8
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingResource.cs
  100. 14
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/IOperationRateLimitingChecker.cs

220
.claude/skills/abp-angular/SKILL.md

@ -0,0 +1,220 @@
---
name: abp-angular
description: ABP Angular UI patterns - generate-proxy, ListService, PermissionGuard, abpLocalization pipe, ConfirmationService, ToasterService, ConfigStateService. Use when building or reviewing Angular UI components, routing, or service integration in ABP Angular projects.
---
# ABP Angular UI
> **Docs**: https://abp.io/docs/latest/framework/ui/angular/overview
## Project Structure
```
src/app/
├── proxy/ # Auto-generated service proxies
├── shared/ # Shared components, pipes, directives
├── book/ # Feature module
│ ├── book.module.ts
│ ├── book-routing.module.ts
│ ├── book-list/
│ │ ├── book-list.component.ts
│ │ ├── book-list.component.html
│ │ └── book-list.component.scss
│ └── book-detail/
```
## Generate Service Proxies
```bash
abp generate-proxy -t ng
```
This generates typed service classes in `src/app/proxy/`.
## List Component Pattern
```typescript
@Component({
selector: 'app-book-list',
templateUrl: './book-list.component.html'
})
export class BookListComponent implements OnInit {
books = { items: [], totalCount: 0 } as PagedResultDto<BookDto>;
constructor(
public readonly list: ListService,
private bookService: BookService,
private confirmation: ConfirmationService
) {}
ngOnInit(): void {
this.hookToQuery();
}
private hookToQuery(): void {
this.list.hookToQuery(query =>
this.bookService.getList(query)
).subscribe(response => {
this.books = response;
});
}
create(): void {
// Open create modal
}
delete(book: BookDto): void {
this.confirmation
.warn('::AreYouSureToDelete', '::AreYouSure')
.subscribe(status => {
if (status === Confirmation.Status.confirm) {
this.bookService.delete(book.id).subscribe(() => this.list.get());
}
});
}
}
```
## Localization
```typescript
// In component
constructor(private localizationService: LocalizationService) {}
getText(): string {
return this.localizationService.instant('::Books');
}
```
```html
<!-- In template -->
<h1>{{ '::Books' | abpLocalization }}</h1>
<!-- With parameters -->
<p>{{ '::WelcomeMessage' | abpLocalization: userName }}</p>
```
## Authorization
### Permission Directive
```html
<button *abpPermission="'BookStore.Books.Create'">Create</button>
```
### Permission Guard
```typescript
const routes: Routes = [
{
path: '',
component: BookListComponent,
canActivate: [PermissionGuard],
data: {
requiredPolicy: 'BookStore.Books'
}
}
];
```
### Programmatic Check
```typescript
constructor(private permissionService: PermissionService) {}
canCreate(): boolean {
return this.permissionService.getGrantedPolicy('BookStore.Books.Create');
}
```
## Forms with Validation
```typescript
@Component({...})
export class BookFormComponent {
form: FormGroup;
constructor(private fb: FormBuilder) {
this.buildForm();
}
buildForm(): void {
this.form = this.fb.group({
name: ['', [Validators.required, Validators.maxLength(128)]],
price: [0, [Validators.required, Validators.min(0)]]
});
}
save(): void {
if (this.form.invalid) return;
this.bookService.create(this.form.value).subscribe(() => {
// Handle success
});
}
}
```
```html
<form [formGroup]="form" (ngSubmit)="save()">
<div class="form-group">
<label for="name">{{ '::Name' | abpLocalization }}</label>
<input type="text" id="name" formControlName="name" class="form-control" />
</div>
<button type="submit" class="btn btn-primary" [disabled]="form.invalid">
{{ '::Save' | abpLocalization }}
</button>
</form>
```
## Configuration API
```typescript
constructor(private configService: ConfigStateService) {}
getCurrentUser(): CurrentUserDto {
return this.configService.getOne('currentUser');
}
getSettings(): void {
const setting = this.configService.getSetting('MyApp.MaxItemCount');
}
```
## Modal Service
```typescript
constructor(private modalService: ModalService) {}
openCreateModal(): void {
const modalRef = this.modalService.open(BookFormComponent, {
size: 'lg'
});
modalRef.result.then(result => {
if (result) {
this.list.get();
}
});
}
```
## Toast Notifications
```typescript
constructor(private toaster: ToasterService) {}
showSuccess(): void {
this.toaster.success('::BookCreatedSuccessfully', '::Success');
}
showError(error: string): void {
this.toaster.error(error, '::Error');
}
```
## Lazy Loading Modules
```typescript
// app-routing.module.ts
const routes: Routes = [
{
path: 'books',
loadChildren: () => import('./book/book.module').then(m => m.BookModule)
}
];
```
## Theme & Styling
- Use Bootstrap classes
- ABP provides theme variables via CSS custom properties
- Component-specific styles in `.component.scss`

78
.claude/skills/abp-app-nolayers/SKILL.md

@ -0,0 +1,78 @@
---
name: abp-app-nolayers
description: ABP Single-Layer (No-Layers / nolayers) application template - single project structure, feature-based file organization, no separate Domain/Application.Contracts projects. Use when working with the single-layer web application template or when the project has no layered separation.
---
# ABP Single-Layer Application Template
> **Docs**: https://abp.io/docs/latest/solution-templates/single-layer-web-application
## Solution Structure
Single project containing everything:
```
MyProject/
├── src/
│ └── MyProject/
│ ├── Data/ # DbContext, migrations
│ ├── Entities/ # Domain entities
│ ├── Services/ # Application services + DTOs
│ ├── Pages/ # Razor pages / Blazor components
│ └── MyProjectModule.cs
└── test/
└── MyProject.Tests/
```
## Key Differences from Layered
| Layered Template | Single-Layer Template |
|------------------|----------------------|
| DTOs in Application.Contracts | DTOs in Services folder (same project) |
| Repository interfaces in Domain | Use generic `IRepository<T, TKey>` directly |
| Separate Domain.Shared for constants | Constants in same project |
| Multiple module classes | Single module class |
## File Organization
Group related files by feature:
```
Services/
├── Books/
│ ├── BookAppService.cs
│ ├── BookDto.cs
│ ├── CreateBookDto.cs
│ └── IBookAppService.cs
└── Authors/
├── AuthorAppService.cs
└── ...
```
## Simplified Entity (Still keep invariants)
Single-layer templates are structurally simpler, but you may still have real business invariants.
- For **trivial CRUD** entities, public setters can be acceptable.
- For **non-trivial business rules**, still prefer encapsulation (private setters + methods) to prevent invalid states.
```csharp
public class Book : AuditedAggregateRoot<Guid>
{
public string Name { get; set; } // OK for trivial CRUD only
public decimal Price { get; set; }
}
```
## No Custom Repository Needed
Use generic repository directly - no need to define custom interfaces:
```csharp
public class BookAppService : ApplicationService
{
private readonly IRepository<Book, Guid> _bookRepository;
// Generic repository is sufficient for single-layer apps
}
```

239
.claude/skills/abp-application-layer/SKILL.md

@ -0,0 +1,239 @@
---
name: abp-application-layer
description: ABP Application Services, DTOs, CRUD service, object mapping (Mapperly/AutoMapper), validation, error handling. Use when creating or reviewing application services, DTOs, or working in the Application or Application.Contracts projects.
---
# ABP Application Layer Patterns
> **Docs**: https://abp.io/docs/latest/framework/architecture/domain-driven-design/application-services
## Anti-Patterns to Avoid
- **Entity name in method**: use `GetAsync` not `GetBookAsync`
- **ID inside UpdateDto**: pass `id` as a separate parameter, not inside the DTO
- **Calling other app services in the same module**: use domain services or repositories directly
- **Using `IFormFile`/`Stream` in app service**: accept `byte[]` from controllers instead
- **Business logic in app service**: put it in domain entities or domain services
## Application Service Structure
### Interface (Application.Contracts)
```csharp
public interface IBookAppService : IApplicationService
{
Task<BookDto> GetAsync(Guid id);
Task<PagedResultDto<BookListItemDto>> GetListAsync(GetBookListInput input);
Task<BookDto> CreateAsync(CreateBookDto input);
Task<BookDto> UpdateAsync(Guid id, UpdateBookDto input);
Task DeleteAsync(Guid id);
}
```
### Implementation (Application)
```csharp
public class BookAppService : ApplicationService, IBookAppService
{
private readonly IBookRepository _bookRepository;
private readonly BookManager _bookManager;
private readonly BookMapper _bookMapper;
public BookAppService(
IBookRepository bookRepository,
BookManager bookManager,
BookMapper bookMapper)
{
_bookRepository = bookRepository;
_bookManager = bookManager;
_bookMapper = bookMapper;
}
public async Task<BookDto> GetAsync(Guid id)
{
var book = await _bookRepository.GetAsync(id);
return _bookMapper.MapToDto(book);
}
[Authorize(BookStorePermissions.Books.Create)]
public async Task<BookDto> CreateAsync(CreateBookDto input)
{
var book = await _bookManager.CreateAsync(input.Name, input.Price);
await _bookRepository.InsertAsync(book);
return _bookMapper.MapToDto(book);
}
[Authorize(BookStorePermissions.Books.Edit)]
public async Task<BookDto> UpdateAsync(Guid id, UpdateBookDto input)
{
var book = await _bookRepository.GetAsync(id);
await _bookManager.ChangeNameAsync(book, input.Name);
book.SetPrice(input.Price);
await _bookRepository.UpdateAsync(book);
return _bookMapper.MapToDto(book);
}
}
```
## Application Service Best Practices
- Don't repeat entity name in method names (`GetAsync` not `GetBookAsync`)
- Accept/return DTOs only, never entities
- ID not inside UpdateDto - pass separately
- Use custom repositories when you need custom queries, generic repository is fine for simple CRUD
- Call `UpdateAsync` explicitly (don't assume change tracking)
- Don't call other app services in same module
- Don't use `IFormFile`/`Stream` - pass `byte[]` from controllers
- Use base class properties (`Clock`, `CurrentUser`, `GuidGenerator`, `L`) instead of injecting these services
## DTO Naming Conventions
| Purpose | Convention | Example |
|---------|------------|---------|
| Query input | `Get{Entity}Input` | `GetBookInput` |
| List query input | `Get{Entity}ListInput` | `GetBookListInput` |
| Create input | `Create{Entity}Dto` | `CreateBookDto` |
| Update input | `Update{Entity}Dto` | `UpdateBookDto` |
| Single entity output | `{Entity}Dto` | `BookDto` |
| List item output | `{Entity}ListItemDto` | `BookListItemDto` |
## DTO Location
- Define DTOs in `*.Application.Contracts` project
- This allows sharing with clients (Blazor, HttpApi.Client)
## Validation
### Data Annotations
```csharp
public class CreateBookDto
{
[Required]
[StringLength(100, MinimumLength = 3)]
public string Name { get; set; }
[Range(0, 999.99)]
public decimal Price { get; set; }
}
```
### Custom Validation with IValidatableObject
Before adding custom validation, decide if it's a **domain rule** or **application rule**:
- **Domain rule**: Put validation in entity constructor or domain service (enforces business invariants)
- **Application rule**: Use DTO validation (input format, required fields)
Only use `IValidatableObject` for application-level validation that can't be expressed with data annotations:
```csharp
public class CreateBookDto : IValidatableObject
{
public string Name { get; set; }
public string Description { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Name == Description)
{
yield return new ValidationResult(
"Name and Description cannot be the same!",
new[] { nameof(Name), nameof(Description) }
);
}
}
}
```
### FluentValidation
```csharp
public class CreateBookDtoValidator : AbstractValidator<CreateBookDto>
{
public CreateBookDtoValidator()
{
RuleFor(x => x.Name).NotEmpty().Length(3, 100);
RuleFor(x => x.Price).GreaterThan(0);
}
}
```
## Error Handling
### Business Exceptions
```csharp
throw new BusinessException("BookStore:010001")
.WithData("BookName", name);
```
### Entity Not Found
```csharp
var book = await _bookRepository.FindAsync(id);
if (book == null)
{
throw new EntityNotFoundException(typeof(Book), id);
}
```
### User-Friendly Exceptions
```csharp
throw new UserFriendlyException(L["BookNotAvailable"]);
```
### HTTP Status Code Mapping
Status code mapping is **configurable** in ABP (do not rely on a fixed mapping in business logic).
| Exception | Typical HTTP Status |
|-----------|-------------|
| `AbpValidationException` | 400 |
| `AbpAuthorizationException` | 401/403 |
| `EntityNotFoundException` | 404 |
| `BusinessException` | 403 (but configurable) |
| Other exceptions | 500 |
## Auto API Controllers
ABP automatically generates API controllers for application services:
- Interface must inherit `IApplicationService` (which already has `[RemoteService]` attribute)
- HTTP methods determined by method name prefix (Get, Create, Update, Delete)
- Use `[RemoteService(false)]` to disable auto API generation for specific methods
## Object Mapping (Mapperly / AutoMapper)
ABP supports **both Mapperly and AutoMapper** integrations. But the default mapping library is Mapperly. You need to first check the project's active mapping library.
- Prefer the mapping provider already used in the solution (check existing mapping files / loaded modules).
- In mixed solutions, explicitly setting the default provider may be required (see `docs/en/release-info/migration-guides/AutoMapper-To-Mapperly.md`).
### Mapperly (compile-time)
Define mappers as partial classes:
```csharp
[Mapper]
public partial class BookMapper
{
public partial BookDto MapToDto(Book book);
public partial List<BookDto> MapToDtoList(List<Book> books);
}
```
Register in module:
```csharp
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddSingleton<BookMapper>();
}
```
Usage in application service:
```csharp
public class BookAppService : ApplicationService
{
private readonly BookMapper _bookMapper;
public BookAppService(BookMapper bookMapper)
{
_bookMapper = bookMapper;
}
public BookDto GetBook(Book book)
{
return _bookMapper.MapToDto(book);
}
}
```
> **Note**: Mapperly generates mapping code at compile-time, providing better performance than runtime mappers.
### AutoMapper (runtime)
If the solution uses AutoMapper, mappings are typically defined in `Profile` classes and registered via ABP's AutoMapper integration.

182
.claude/skills/abp-authorization/SKILL.md

@ -0,0 +1,182 @@
---
name: abp-authorization
description: ABP permission system - PermissionDefinitionProvider, [Authorize] attribute, CheckPolicyAsync, IsGrantedAsync, ICurrentUser, IPermissionManager, multi-tenancy side. Use when working with permissions, authorization, role-based access, or security in ABP projects.
---
# ABP Authorization
> **Docs**: https://abp.io/docs/latest/framework/fundamentals/authorization
## Permission Definition
Define permissions in `*.Application.Contracts` project:
```csharp
public static class BookStorePermissions
{
public const string GroupName = "BookStore";
public static class Books
{
public const string Default = GroupName + ".Books";
public const string Create = Default + ".Create";
public const string Edit = Default + ".Edit";
public const string Delete = Default + ".Delete";
}
}
```
Register in provider:
```csharp
public class BookStorePermissionDefinitionProvider : PermissionDefinitionProvider
{
public override void Define(IPermissionDefinitionContext context)
{
var bookStoreGroup = context.AddGroup(BookStorePermissions.GroupName, L("Permission:BookStore"));
var booksPermission = bookStoreGroup.AddPermission(
BookStorePermissions.Books.Default,
L("Permission:Books"));
booksPermission.AddChild(
BookStorePermissions.Books.Create,
L("Permission:Books.Create"));
booksPermission.AddChild(
BookStorePermissions.Books.Edit,
L("Permission:Books.Edit"));
booksPermission.AddChild(
BookStorePermissions.Books.Delete,
L("Permission:Books.Delete"));
}
private static LocalizableString L(string name)
{
return LocalizableString.Create<BookStoreResource>(name);
}
}
```
## Using Permissions
### Declarative (Attribute)
```csharp
[Authorize(BookStorePermissions.Books.Create)]
public virtual async Task<BookDto> CreateAsync(CreateBookDto input)
{
// Only users with Books.Create permission can execute
}
```
### Programmatic Check
```csharp
public class BookAppService : ApplicationService
{
public async Task DoSomethingAsync()
{
// Check and throw if not granted
await CheckPolicyAsync(BookStorePermissions.Books.Edit);
// Or check without throwing
if (await IsGrantedAsync(BookStorePermissions.Books.Delete))
{
// Has permission
}
}
}
```
### Allow Anonymous Access
```csharp
[AllowAnonymous]
public virtual async Task<BookDto> GetPublicBookAsync(Guid id)
{
// No authentication required
}
```
## Current User
Access authenticated user info via `CurrentUser` property (available in base classes like `ApplicationService`, `DomainService`, `AbpController`):
```csharp
public class BookAppService : ApplicationService
{
public async Task DoSomethingAsync()
{
// CurrentUser is available from base class - no injection needed
var userId = CurrentUser.Id;
var userName = CurrentUser.UserName;
var email = CurrentUser.Email;
var isAuthenticated = CurrentUser.IsAuthenticated;
var roles = CurrentUser.Roles;
var tenantId = CurrentUser.TenantId;
}
}
// In other services, inject ICurrentUser
public class MyService : ITransientDependency
{
private readonly ICurrentUser _currentUser;
public MyService(ICurrentUser currentUser) => _currentUser = currentUser;
}
```
### Ownership Validation
```csharp
public async Task UpdateMyBookAsync(Guid bookId, UpdateBookDto input)
{
var book = await _bookRepository.GetAsync(bookId);
if (book.CreatorId != CurrentUser.Id)
{
throw new AbpAuthorizationException();
}
// Update book...
}
```
## Multi-Tenancy Permissions
Control permission availability per tenant side:
```csharp
bookStoreGroup.AddPermission(
BookStorePermissions.Books.Default,
L("Permission:Books"),
multiTenancySide: MultiTenancySides.Tenant // Only for tenants
);
```
Options: `MultiTenancySides.Host`, `Tenant`, or `Both`
## Feature-Dependent Permissions
```csharp
booksPermission.RequireFeatures("BookStore.PremiumFeature");
```
## Permission Management
Grant/revoke permissions programmatically:
```csharp
public class MyService : ITransientDependency
{
private readonly IPermissionManager _permissionManager;
public async Task GrantPermissionToUserAsync(Guid userId, string permissionName)
{
await _permissionManager.SetForUserAsync(userId, permissionName, true);
}
public async Task GrantPermissionToRoleAsync(string roleName, string permissionName)
{
await _permissionManager.SetForRoleAsync(roleName, permissionName, true);
}
}
```
## Security Best Practices
- Never trust client input for user identity
- Use `CurrentUser` property (from base class) or inject `ICurrentUser`
- Validate ownership in application service methods
- Filter queries by current user when appropriate
- Don't expose sensitive fields in DTOs

206
.claude/skills/abp-blazor/SKILL.md

@ -0,0 +1,206 @@
---
name: abp-blazor
description: ABP Blazor UI patterns - AbpComponentBase, AbpCrudPageBase, DataGrid, IMenuContributor, Message/Notify, Validations, JavaScript interop. Use when building or reviewing Blazor Server or WebAssembly UI components in ABP projects.
---
# ABP Blazor UI
> **Docs**: https://abp.io/docs/latest/framework/ui/blazor/overall
## Component Base Classes
### Basic Component
```razor
@inherits AbpComponentBase
<h1>@L["Books"]</h1>
```
### CRUD Page
```razor
@page "/books"
@inherits AbpCrudPageBase<IBookAppService, BookDto, Guid, PagedAndSortedResultRequestDto, CreateUpdateBookDto>
<Card>
<CardHeader>
<Row>
<Column>
<h2>@L["Books"]</h2>
</Column>
<Column TextAlignment="TextAlignment.End">
@if (HasCreatePermission)
{
<Button Color="Color.Primary" Clicked="OpenCreateModalAsync">
@L["NewBook"]
</Button>
}
</Column>
</Row>
</CardHeader>
<CardBody>
<DataGrid TItem="BookDto"
Data="Entities"
ReadData="OnDataGridReadAsync"
TotalItems="TotalCount"
ShowPager="true"
PageSize="PageSize">
<DataGridColumns>
<DataGridColumn Field="@nameof(BookDto.Name)" Caption="@L["Name"]" />
<DataGridColumn Field="@nameof(BookDto.Price)" Caption="@L["Price"]" />
<DataGridEntityActionsColumn TItem="BookDto">
<DisplayTemplate>
<EntityActions TItem="BookDto">
<EntityAction TItem="BookDto"
Text="@L["Edit"]"
Visible="HasUpdatePermission"
Clicked="() => OpenEditModalAsync(context)" />
<EntityAction TItem="BookDto"
Text="@L["Delete"]"
Visible="HasDeletePermission"
Clicked="() => DeleteEntityAsync(context)"
ConfirmationMessage="() => GetDeleteConfirmationMessage(context)" />
</EntityActions>
</DisplayTemplate>
</DataGridEntityActionsColumn>
</DataGridColumns>
</DataGrid>
</CardBody>
</Card>
```
## Localization
```razor
@* Using L property from base class *@
<h1>@L["PageTitle"]</h1>
@* With parameters *@
<p>@L["WelcomeMessage", CurrentUser.UserName]</p>
```
## Authorization
```razor
@* Check permission before rendering *@
@if (await AuthorizationService.IsGrantedAsync("MyPermission"))
{
<Button>Admin Action</Button>
}
@* Using policy-based authorization *@
<AuthorizeView Policy="MyPolicy">
<Authorized>
<p>You have access!</p>
</Authorized>
</AuthorizeView>
```
## Navigation & Menu
Configure in `*MenuContributor.cs`:
```csharp
public class MyMenuContributor : IMenuContributor
{
public async Task ConfigureMenuAsync(MenuConfigurationContext context)
{
if (context.Menu.Name == StandardMenus.Main)
{
var bookMenu = new ApplicationMenuItem(
"Books",
l["Menu:Books"],
"/books",
icon: "fa fa-book"
);
if (await context.IsGrantedAsync(MyPermissions.Books.Default))
{
context.Menu.AddItem(bookMenu);
}
}
}
}
```
## Notifications & Messages
```csharp
// Success message
await Message.Success(L["BookCreatedSuccessfully"]);
// Confirmation dialog
if (await Message.Confirm(L["AreYouSure"]))
{
// User confirmed
}
// Toast notification
await Notify.Success(L["OperationCompleted"]);
```
## Forms & Validation
```razor
<Form @ref="CreateForm">
<Validations @ref="CreateValidationsRef" Model="@NewEntity" ValidateOnLoad="false">
<Validation MessageLocalizer="@LH.Localize">
<Field>
<FieldLabel>@L["Name"]</FieldLabel>
<TextEdit @bind-Text="@NewEntity.Name">
<Feedback>
<ValidationError />
</Feedback>
</TextEdit>
</Field>
</Validation>
</Validations>
</Form>
```
## JavaScript Interop
```csharp
@inject IJSRuntime JsRuntime
@code {
private async Task CallJavaScript()
{
await JsRuntime.InvokeVoidAsync("myFunction", arg1, arg2);
var result = await JsRuntime.InvokeAsync<string>("myFunctionWithReturn");
}
}
```
## State Management
```csharp
// Inject service proxy from HttpApi.Client
@inject IBookAppService BookAppService
@code {
private List<BookDto> Books { get; set; }
protected override async Task OnInitializedAsync()
{
var result = await BookAppService.GetListAsync(new PagedAndSortedResultRequestDto());
Books = result.Items.ToList();
}
}
```
## Code-Behind Pattern
**Books.razor:**
```razor
@page "/books"
@inherits BooksBase
```
**Books.razor.cs:**
```csharp
public partial class Books : BooksBase
{
// Component logic here
}
```
**BooksBase.cs:**
```csharp
public abstract class BooksBase : AbpComponentBase
{
[Inject]
protected IBookAppService BookAppService { get; set; }
}
```

89
.claude/skills/abp-cli/SKILL.md

@ -0,0 +1,89 @@
---
name: abp-cli
description: ABP CLI commands - generate-proxy, install-libs, add-package-ref, new-module, install-module, abp update, abp clean, abp suite generate. Use when the user asks how to run ABP CLI commands, generate proxies, install libraries, or use ABP Suite.
---
# ABP CLI Commands
> **Full documentation**: https://abp.io/docs/latest/cli
> Use `abp help [command]` for detailed options.
## Generate Client Proxies
```bash
# URL flag: `-u` (short) or `--url` (long). Use whichever your team prefers, but keep it consistent.
#
# Angular (host must be running)
abp generate-proxy -t ng
# C# client proxies
abp generate-proxy -t csharp -u https://localhost:44300
# Integration services only (microservices)
abp generate-proxy -t csharp -u https://localhost:44300 -st integration
# JavaScript
abp generate-proxy -t js -u https://localhost:44300
```
## Install Client-Side Libraries
```bash
# Install NPM packages for MVC/Blazor Server
abp install-libs
```
## Add Package Reference
```bash
# Add project reference with module dependency
abp add-package-ref Acme.BookStore.Domain
abp add-package-ref Acme.BookStore.Domain -t Acme.BookStore.Application
```
## Module Operations
```bash
# Create new module in solution
abp new-module Acme.OrderManagement -t module:ddd
# Install published module
abp install-module Volo.Blogging
# Add ABP NuGet package
abp add-package Volo.Abp.Caching.StackExchangeRedis
```
## Update & Clean
```bash
abp update # Update all ABP packages
abp update --version 8.0.0 # Specific version
abp clean # Delete bin/obj folders
```
## ABP Suite (CRUD Generation)
Generate CRUD pages from entity JSON (created via Suite UI):
```bash
abp suite generate --entity .suite/entities/Book.json --solution ./Acme.BookStore.sln
```
> **Note**: Entity JSON files are created when you generate an entity via ABP Suite UI. They are stored in `.suite/entities/` folder.
> **Suite docs**: https://abp.io/docs/latest/suite
## Quick Reference
| Task | Command |
|------|---------|
| Angular proxies | `abp generate-proxy -t ng` |
| C# proxies | `abp generate-proxy -t csharp -u URL` |
| Install JS libs | `abp install-libs` |
| Add reference | `abp add-package-ref PackageName` |
| Create module | `abp new-module ModuleName` |
| Install module | `abp install-module ModuleName` |
| Update packages | `abp update` |
| Clean solution | `abp clean` |
| Suite CRUD | `abp suite generate -e entity.json -s solution.sln` |
| Get help | `abp help [command]` |

190
.claude/skills/abp-core/SKILL.md

@ -0,0 +1,190 @@
---
name: abp-core
description: Core ABP Framework conventions - module system, DI registration, base classes (ApplicationService, DomainService), IClock, BusinessException, localization, async patterns. Use when working on any ABP project, asking about ABP fundamentals, or unsure which skill applies.
---
# ABP Core Conventions
> **Documentation**: https://abp.io/docs/latest
> **API Reference**: https://abp.io/docs/api/
## Key Rules
- Use `IClock` / `Clock.Now` instead of `DateTime.Now` / `DateTime.UtcNow`
- Use `ITransientDependency` / `ISingletonDependency` instead of `AddScoped/AddTransient/AddSingleton`
- Use `IRepository<T>` instead of injecting `DbContext` directly
- Check base class properties (`Clock`, `CurrentUser`, `GuidGenerator`, `L`) before injecting services
- Use `BusinessException` with namespaced error codes for domain rule violations
## Module System
Every ABP application/module has a module class that configures services:
```csharp
[DependsOn(
typeof(AbpDddDomainModule),
typeof(AbpEntityFrameworkCoreModule)
)]
public class MyAppModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
// Service registration and configuration
}
}
```
> **Note**: Middleware configuration (`OnApplicationInitialization`) should only be done in the final host application, not in reusable modules.
## Dependency Injection Conventions
### Automatic Registration
ABP automatically registers services implementing marker interfaces:
- `ITransientDependency` → Transient lifetime
- `ISingletonDependency` → Singleton lifetime
- `IScopedDependency` → Scoped lifetime
Classes inheriting from `ApplicationService`, `DomainService`, `AbpController` are also auto-registered.
### Repository Usage
You can use the generic `IRepository<TEntity, TKey>` for simple CRUD operations. Define custom repository interfaces only when you need custom query methods:
```csharp
// Simple CRUD - Generic repository is fine
public class BookAppService : ApplicationService
{
private readonly IRepository<Book, Guid> _bookRepository; // ✅ OK for simple operations
}
// Custom queries needed - Define custom interface
public interface IBookRepository : IRepository<Book, Guid>
{
Task<Book> FindByNameAsync(string name); // Custom query
}
public class BookAppService : ApplicationService
{
private readonly IBookRepository _bookRepository; // ✅ Use custom when needed
}
```
### Exposing Services
```csharp
[ExposeServices(typeof(IMyService))]
public class MyService : IMyService, ITransientDependency { }
```
## Important Base Classes
| Base Class | Purpose |
|------------|---------|
| `Entity<TKey>` | Basic entity with ID |
| `AggregateRoot<TKey>` | DDD aggregate root |
| `DomainService` | Domain business logic |
| `ApplicationService` | Use case orchestration |
| `AbpController` | REST API controller |
ABP base classes already inject commonly used services as properties. Before injecting a service, check if it's already available:
| Property | Available In | Description |
|----------|--------------|-------------|
| `GuidGenerator` | All base classes | Generate GUIDs |
| `Clock` | All base classes | Current time (use instead of `DateTime`) |
| `CurrentUser` | All base classes | Authenticated user info |
| `CurrentTenant` | All base classes | Multi-tenancy context |
| `L` (StringLocalizer) | `ApplicationService`, `AbpController` | Localization |
| `AuthorizationService` | `ApplicationService`, `AbpController` | Permission checks |
| `FeatureChecker` | `ApplicationService`, `AbpController` | Feature availability |
| `DataFilter` | All base classes | Data filtering (soft-delete, tenant) |
| `UnitOfWorkManager` | `ApplicationService`, `DomainService` | Unit of work management |
| `LoggerFactory` | All base classes | Create loggers |
| `Logger` | All base classes | Logging (auto-created) |
| `LazyServiceProvider` | All base classes | Lazy service resolution |
**Useful methods from base classes:**
- `CheckPolicyAsync()` - Check permission and throw if not granted
- `IsGrantedAsync()` - Check permission without throwing
## Async Best Practices
- Use async all the way - never use `.Result` or `.Wait()`
- All async methods should end with `Async` suffix
- ABP automatically handles `CancellationToken` in most cases (e.g., from `HttpContext.RequestAborted`)
- Only pass `CancellationToken` explicitly when implementing custom cancellation logic
## Time Handling
Never use `DateTime.Now` or `DateTime.UtcNow` directly. Use ABP's `IClock` service:
```csharp
// In classes inheriting from base classes (ApplicationService, DomainService, etc.)
public class BookAppService : ApplicationService
{
public void DoSomething()
{
var now = Clock.Now; // ✅ Already available as property
}
}
// In other services - inject IClock
public class MyService : ITransientDependency
{
private readonly IClock _clock;
public MyService(IClock clock) => _clock = clock;
public void DoSomething()
{
var now = _clock.Now; // ✅ Correct
// var now = DateTime.Now; // ❌ Wrong - not testable, ignores timezone settings
}
}
```
> **Tip**: Before injecting a service, check if it's already available as a property in your base classes.
## Business Exceptions
Use `BusinessException` for domain rule violations with namespaced error codes:
```csharp
throw new BusinessException("MyModule:BookNameAlreadyExists")
.WithData("Name", bookName);
```
Configure localization mapping:
```csharp
Configure<AbpExceptionLocalizationOptions>(options =>
{
options.MapCodeNamespace("MyModule", typeof(MyModuleResource));
});
```
## Localization
- In base classes (`ApplicationService`, `AbpController`, etc.): Use `L["Key"]` - this is the `IStringLocalizer` property
- In other services: Inject `IStringLocalizer<TResource>`
- Always localize user-facing messages and exceptions
**Localization file location**: `*.Domain.Shared/Localization/{ResourceName}/{lang}.json`
```json
// Example: MyProject.Domain.Shared/Localization/MyProject/en.json
{
"culture": "en",
"texts": {
"Menu:Home": "Home",
"Welcome": "Welcome",
"BookName": "Book Name"
}
}
```
## ❌ Never Use (ABP Anti-Patterns)
| Don't Use | Use Instead |
|-----------|-------------|
| Minimal APIs | ABP Controllers or Auto API Controllers |
| MediatR | Application Services |
| `DbContext` directly in App Services | `IRepository<T>` |
| `AddScoped/AddTransient/AddSingleton` | `ITransientDependency`, `ISingletonDependency` |
| `DateTime.Now` | `IClock` / `Clock.Now` |
| Custom UnitOfWork | ABP's `IUnitOfWorkManager` |
| Manual HTTP calls from UI | ABP client proxies (`generate-proxy`) |
| Hardcoded role checks | Permission-based authorization |
| Business logic in Controllers | Application Services |

248
.claude/skills/abp-ddd/SKILL.md

@ -0,0 +1,248 @@
---
name: abp-ddd
description: ABP DDD patterns - Entities, Aggregate Roots, value objects, Repositories, Domain Services, Domain Events, Specifications. Use when designing domain layer, creating entities, repositories, or domain services in ABP projects.
---
# ABP DDD Patterns
> **Docs**: https://abp.io/docs/latest/framework/architecture/domain-driven-design
## Anti-Patterns to Avoid
- **Anemic entities**: public setters with no behavior — use private setters + methods that enforce invariants
- **Repository for child entities**: only aggregate roots get repositories — access child entities through their root
- **Generating GUID in entity constructor**: use `IGuidGenerator` from outside and pass `id` parameter
- **Navigation properties to other aggregates**: reference by `Id` only, never add full navigation properties across aggregates
- **Domain service depending on current user**: accept values from the application layer instead
## Rich Domain Model vs Anemic Domain Model
ABP promotes **Rich Domain Model** pattern where entities contain both data AND behavior:
| Anemic (Anti-pattern) | Rich (Recommended) |
|----------------------|-------------------|
| Entity = data only | Entity = data + behavior |
| Logic in services | Logic in entity methods |
| Public setters | Private setters with methods |
| No validation in entity | Entity enforces invariants |
**Encapsulation is key**: Protect entity state by using private setters and exposing behavior through methods.
## Entities
### Entity Example (Rich Model)
```csharp
public class OrderLine : Entity<Guid>
{
public Guid ProductId { get; private set; }
public int Count { get; private set; }
public decimal Price { get; private set; }
protected OrderLine() { } // For ORM
internal OrderLine(Guid id, Guid productId, int count, decimal price) : base(id)
{
ProductId = productId;
SetCount(count); // Validates through method
Price = price;
}
public void SetCount(int count)
{
if (count <= 0)
throw new BusinessException("Orders:InvalidCount");
Count = count;
}
}
```
## Aggregate Roots
Aggregate roots are consistency boundaries that:
- Own their child entities
- Enforce business rules
- Publish domain events
```csharp
public class Order : AggregateRoot<Guid>
{
public string OrderNumber { get; private set; }
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public ICollection<OrderLine> Lines { get; private set; }
protected Order() { } // For ORM
public Order(Guid id, string orderNumber, Guid customerId) : base(id)
{
OrderNumber = Check.NotNullOrWhiteSpace(orderNumber, nameof(orderNumber));
CustomerId = customerId;
Status = OrderStatus.Created;
Lines = new List<OrderLine>();
}
public void AddLine(Guid lineId, Guid productId, int count, decimal price)
{
// Business rule: Can only add lines to created orders
if (Status != OrderStatus.Created)
throw new BusinessException("Orders:CannotModifyOrder");
Lines.Add(new OrderLine(lineId, productId, count, price));
}
public void Complete()
{
if (Status != OrderStatus.Created)
throw new BusinessException("Orders:CannotCompleteOrder");
Status = OrderStatus.Completed;
// Publish events for side effects
AddLocalEvent(new OrderCompletedEvent(Id)); // Same transaction
AddDistributedEvent(new OrderCompletedEto { OrderId = Id }); // Cross-service
}
}
```
### Domain Events
- `AddLocalEvent()` - Handled within same transaction, can access full entity
- `AddDistributedEvent()` - Handled asynchronously, use ETOs (Event Transfer Objects)
### Entity Best Practices
- **Encapsulation**: Private setters, public methods that enforce rules
- **Primary constructor**: Enforce invariants, accept `id` parameter
- **Protected parameterless constructor**: Required for ORM
- **Initialize collections**: In primary constructor
- **Virtual members**: For ORM proxy compatibility
- **Reference by Id**: Don't add navigation properties to other aggregates
- **Don't generate GUID in constructor**: Use `IGuidGenerator` externally
## Repository Pattern
### When to Use Custom Repository
- **Generic repository** (`IRepository<T, TKey>`): Sufficient for simple CRUD operations
- **Custom repository**: Only when you need custom query methods
### Interface (Domain Layer)
```csharp
// Define custom interface only when custom queries are needed
public interface IOrderRepository : IRepository<Order, Guid>
{
Task<Order> FindByOrderNumberAsync(string orderNumber, bool includeDetails = false);
Task<List<Order>> GetListByCustomerAsync(Guid customerId, bool includeDetails = false);
}
```
### Repository Best Practices
- **One repository per aggregate root only** - Never create repositories for child entities
- Child entities must be accessed/modified only through their aggregate root
- Creating repositories for child entities breaks data consistency (bypasses aggregate root's business rules)
- In ABP, use `AddDefaultRepositories()` without `includeAllEntities: true` to enforce this
- Define custom repository only when custom queries are needed
- ABP handles `CancellationToken` automatically; add parameter only for explicit cancellation control
- Single entity methods: `includeDetails = true` by default
- List methods: `includeDetails = false` by default
- Don't return projection classes
- Interface in Domain, implementation in data layer
```csharp
// ✅ Correct: Repository for aggregate root (Order)
public interface IOrderRepository : IRepository<Order, Guid> { }
// ❌ Wrong: Repository for child entity (OrderLine)
// OrderLine should only be accessed through Order aggregate
public interface IOrderLineRepository : IRepository<OrderLine, Guid> { } // Don't do this!
```
## Domain Services
Use domain services for business logic that:
- Spans multiple aggregates
- Requires repository queries to enforce rules
```csharp
public class OrderManager : DomainService
{
private readonly IOrderRepository _orderRepository;
private readonly IProductRepository _productRepository;
public OrderManager(
IOrderRepository orderRepository,
IProductRepository productRepository)
{
_orderRepository = orderRepository;
_productRepository = productRepository;
}
public async Task<Order> CreateAsync(string orderNumber, Guid customerId)
{
// Business rule: Order number must be unique
var existing = await _orderRepository.FindByOrderNumberAsync(orderNumber);
if (existing != null)
{
throw new BusinessException("Orders:OrderNumberAlreadyExists")
.WithData("OrderNumber", orderNumber);
}
return new Order(GuidGenerator.Create(), orderNumber, customerId);
}
public async Task AddProductAsync(Order order, Guid productId, int count)
{
var product = await _productRepository.GetAsync(productId);
order.AddLine(productId, count, product.Price);
}
}
```
### Domain Service Best Practices
- Use `*Manager` suffix naming
- No interface by default (create only if needed)
- Accept/return domain objects, not DTOs
- Don't depend on authenticated user - pass values from application layer
- Use base class properties (`GuidGenerator`, `Clock`) instead of injecting these services
## Domain Events
### Local Events
```csharp
// In aggregate
AddLocalEvent(new OrderCompletedEvent(Id));
// Handler
public class OrderCompletedEventHandler : ILocalEventHandler<OrderCompletedEvent>, ITransientDependency
{
public async Task HandleEventAsync(OrderCompletedEvent eventData)
{
// Handle within same transaction
}
}
```
### Distributed Events (ETO)
For inter-module/microservice communication:
```csharp
// In Domain.Shared
[EventName("Orders.OrderCompleted")]
public class OrderCompletedEto
{
public Guid OrderId { get; set; }
public string OrderNumber { get; set; }
}
```
## Specifications
Reusable query conditions:
```csharp
public class CompletedOrdersSpec : Specification<Order>
{
public override Expression<Func<Order, bool>> ToExpression()
{
return o => o.Status == OrderStatus.Completed;
}
}
// Usage
var orders = await _orderRepository.GetListAsync(new CompletedOrdersSpec());
```

150
.claude/skills/abp-dependency-rules/SKILL.md

@ -0,0 +1,150 @@
---
name: abp-dependency-rules
description: ABP project layer dependency rules - which projects can reference which, domain/application/infrastructure separation, cross-layer violations to avoid. Use when reviewing project structure, adding new project references, or checking if a dependency direction is correct.
---
# ABP Dependency Rules
## Core Principles (All Templates)
These principles apply regardless of solution structure:
1. **Domain logic never depends on infrastructure** (no DbContext in domain/application)
2. **Use abstractions** (interfaces) for dependencies
3. **Higher layers depend on lower layers**, never the reverse
4. **Data access through repositories**, not direct DbContext
## Layered Template Structure
> **Note**: This section applies to layered templates (app, module). Single-layer and microservice templates have different structures.
```
Domain.Shared → Constants, enums, localization keys
↑
Domain → Entities, repository interfaces, domain services
↑
Application.Contracts → App service interfaces, DTOs
↑
Application → App service implementations
↑
HttpApi → REST controllers (optional)
↑
Host → Final application with DI and middleware
```
### Layered Dependency Direction
| Project | Can Reference | Referenced By |
|---------|---------------|---------------|
| Domain.Shared | Nothing | All |
| Domain | Domain.Shared | Application, Data layer |
| Application.Contracts | Domain.Shared | Application, HttpApi, Clients |
| Application | Domain, Contracts | Host |
| EntityFrameworkCore/MongoDB | Domain | Host only |
| HttpApi | Contracts only | Host |
## Critical Rules
### ❌ Never Do
```csharp
// Application layer accessing DbContext directly
public class BookAppService : ApplicationService
{
private readonly MyDbContext _dbContext; // ❌ WRONG
}
// Domain depending on application layer
public class BookManager : DomainService
{
private readonly IBookAppService _appService; // ❌ WRONG
}
// HttpApi depending on Application implementation
public class BookController : AbpController
{
private readonly BookAppService _bookAppService; // ❌ WRONG - Use interface
}
```
### ✅ Always Do
```csharp
// Application layer using repository abstraction
public class BookAppService : ApplicationService
{
private readonly IBookRepository _bookRepository; // ✅ CORRECT
}
// Domain service using domain abstractions
public class BookManager : DomainService
{
private readonly IBookRepository _bookRepository; // ✅ CORRECT
}
// HttpApi depending on contracts only
public class BookController : AbpController
{
private readonly IBookAppService _bookAppService; // ✅ CORRECT
}
```
## Repository Pattern Enforcement
### Interface Location
```csharp
// In Domain project
public interface IBookRepository : IRepository<Book, Guid>
{
Task<Book> FindByNameAsync(string name);
}
```
### Implementation Location
```csharp
// In EntityFrameworkCore project
public class BookRepository : EfCoreRepository<MyDbContext, Book, Guid>, IBookRepository
{
// Implementation
}
// In MongoDB project
public class BookRepository : MongoDbRepository<MyDbContext, Book, Guid>, IBookRepository
{
// Implementation
}
```
## Multi-Application Scenarios
When you have multiple applications (e.g., Admin + Public API):
### Vertical Separation
```
MyProject.Admin.Application - Admin-specific services
MyProject.Public.Application - Public-specific services
MyProject.Domain - Shared domain (both reference this)
```
### Rules
- Admin and Public application layers **MUST NOT** reference each other
- Share domain logic, not application logic
- Each vertical can have its own DTOs even if similar
## Enforcement Checklist (Layered Templates)
When adding a new feature:
1. **Entity changes?** → Domain project
2. **Constants/enums?** → Domain.Shared project
3. **Repository interface?** → Domain project (only if custom queries needed)
4. **Repository implementation?** → EntityFrameworkCore/MongoDB project
5. **DTOs and service interface?** → Application.Contracts project
6. **Service implementation?** → Application project
7. **API endpoint?** → HttpApi project (if not using auto API controllers)
## Common Violations to Watch
| Violation | Impact | Fix |
|-----------|--------|-----|
| DbContext in Application | Breaks DB independence | Use repository |
| Entity in DTO | Exposes internals | Map to DTO |
| IQueryable in interface | Breaks abstraction | Return concrete types |
| Cross-module app service call | Tight coupling | Use events or domain |

261
.claude/skills/abp-development-flow/SKILL.md

@ -0,0 +1,261 @@
---
name: abp-development-flow
description: ABP development workflow - step-by-step guide for adding new entities, migrations, application services, localization, permissions, and tests. Use when adding new features or entities to an ABP project.
---
# ABP Development Workflow
> **Tutorials**: https://abp.io/docs/latest/tutorials
## Adding a New Entity (Full Flow)
### 1. Domain Layer
Create entity (location varies by template: `*.Domain/Entities/` for layered, `Entities/` for single-layer/microservice):
```csharp
public class Book : AggregateRoot<Guid>
{
public string Name { get; private set; }
public decimal Price { get; private set; }
public Guid AuthorId { get; private set; }
protected Book() { }
public Book(Guid id, string name, decimal price, Guid authorId) : base(id)
{
Name = Check.NotNullOrWhiteSpace(name, nameof(name));
SetPrice(price);
AuthorId = authorId;
}
public void SetPrice(decimal price)
{
Price = Check.Range(price, nameof(price), 0, 9999);
}
}
```
### 2. Domain.Shared
Add constants and enums in `*.Domain.Shared/`:
```csharp
public static class BookConsts
{
public const int MaxNameLength = 128;
}
public enum BookType
{
Novel,
Science,
Biography
}
```
### 3. Repository Interface (Optional)
Define custom repository in `*.Domain/` only if you need custom query methods. For simple CRUD, use generic `IRepository<Book, Guid>` directly:
```csharp
// Only if custom queries are needed
public interface IBookRepository : IRepository<Book, Guid>
{
Task<Book> FindByNameAsync(string name);
}
```
### 4. EF Core Configuration
In `*.EntityFrameworkCore/`:
**DbContext:**
```csharp
public DbSet<Book> Books { get; set; }
```
**OnModelCreating:**
```csharp
builder.Entity<Book>(b =>
{
b.ToTable(MyProjectConsts.DbTablePrefix + "Books", MyProjectConsts.DbSchema);
b.ConfigureByConvention();
b.Property(x => x.Name).IsRequired().HasMaxLength(BookConsts.MaxNameLength);
b.HasIndex(x => x.Name);
});
```
**Repository Implementation (only if custom interface defined):**
```csharp
public class BookRepository : EfCoreRepository<MyDbContext, Book, Guid>, IBookRepository
{
public BookRepository(IDbContextProvider<MyDbContext> dbContextProvider)
: base(dbContextProvider)
{
}
public async Task<Book> FindByNameAsync(string name)
{
return await (await GetDbSetAsync())
.FirstOrDefaultAsync(b => b.Name == name);
}
}
```
### 5. Run Migration
See `abp-ef-core` skill for migration commands. Recommended: use `DbMigrator` project to apply migrations and seed data.
### 6. Application.Contracts
Create DTOs and service interface:
```csharp
// DTOs
public class BookDto : EntityDto<Guid>
{
public string Name { get; set; }
public decimal Price { get; set; }
public Guid AuthorId { get; set; }
}
public class CreateBookDto
{
[Required]
[StringLength(BookConsts.MaxNameLength)]
public string Name { get; set; }
[Range(0, 9999)]
public decimal Price { get; set; }
[Required]
public Guid AuthorId { get; set; }
}
// Service Interface
public interface IBookAppService : IApplicationService
{
Task<BookDto> GetAsync(Guid id);
Task<PagedResultDto<BookDto>> GetListAsync(PagedAndSortedResultRequestDto input);
Task<BookDto> CreateAsync(CreateBookDto input);
}
```
### 7. Object Mapping (Mapperly / AutoMapper)
ABP supports both Mapperly and AutoMapper. Prefer the provider already used in the solution.
If the solution uses **Mapperly**, create a mapper in the Application project:
```csharp
[Mapper]
public partial class BookMapper
{
public partial BookDto MapToDto(Book book);
public partial List<BookDto> MapToDtoList(List<Book> books);
}
```
Register in module:
```csharp
context.Services.AddSingleton<BookMapper>();
```
### 8. Application Service
Implement service (using generic repository - use `IBookRepository` if you defined custom interface in step 3):
```csharp
public class BookAppService : ApplicationService, IBookAppService
{
private readonly IRepository<Book, Guid> _bookRepository; // Or IBookRepository
private readonly BookMapper _bookMapper;
public BookAppService(
IRepository<Book, Guid> bookRepository,
BookMapper bookMapper)
{
_bookRepository = bookRepository;
_bookMapper = bookMapper;
}
public async Task<BookDto> GetAsync(Guid id)
{
var book = await _bookRepository.GetAsync(id);
return _bookMapper.MapToDto(book);
}
[Authorize(MyProjectPermissions.Books.Create)]
public async Task<BookDto> CreateAsync(CreateBookDto input)
{
var book = new Book(
GuidGenerator.Create(),
input.Name,
input.Price,
input.AuthorId
);
await _bookRepository.InsertAsync(book);
return _bookMapper.MapToDto(book);
}
}
```
### 9. Add Localization
In `*.Domain.Shared/Localization/*/en.json`:
```json
{
"Book": "Book",
"Books": "Books",
"BookName": "Name",
"BookPrice": "Price"
}
```
### 10. Add Permissions (if needed)
```csharp
public static class MyProjectPermissions
{
public static class Books
{
public const string Default = "MyProject.Books";
public const string Create = Default + ".Create";
}
}
```
### 11. Add Tests
```csharp
public class BookAppService_Tests : MyProjectApplicationTestBase
{
private readonly IBookAppService _bookAppService;
public BookAppService_Tests()
{
_bookAppService = GetRequiredService<IBookAppService>();
}
[Fact]
public async Task Should_Create_Book()
{
var result = await _bookAppService.CreateAsync(new CreateBookDto
{
Name = "Test Book",
Price = 19.99m
});
result.Id.ShouldNotBe(Guid.Empty);
result.Name.ShouldBe("Test Book");
}
}
```
## Checklist for New Features
- [ ] Entity created with proper constructors
- [ ] Constants in Domain.Shared
- [ ] Custom repository interface in Domain (only if custom queries needed)
- [ ] EF Core configuration added
- [ ] Custom repository implementation (only if interface defined)
- [ ] Migration generated and applied (use DbMigrator)
- [ ] Mapperly mapper created and registered
- [ ] DTOs created in Application.Contracts
- [ ] Service interface defined
- [ ] Service implementation with authorization
- [ ] Localization keys added
- [ ] Permissions defined (if applicable)
- [ ] Tests written

262
.claude/skills/abp-ef-core/SKILL.md

@ -0,0 +1,262 @@
---
name: abp-ef-core
description: ABP Entity Framework Core - DbContext, entity configuration, EfCoreRepository implementation, migrations (dotnet ef migrations add), data seeding. Use when working in EntityFrameworkCore projects, adding migrations, or implementing EF Core repositories.
---
# ABP Entity Framework Core
> **Docs**: https://abp.io/docs/latest/framework/data/entity-framework-core
## Never Do
| Don't | Do Instead |
|-------|-----------|
| Skip `b.ConfigureByConvention()` | Always call it first in entity config |
| `AddDefaultRepositories(includeAllEntities: true)` | Use `AddDefaultRepositories()` only for aggregate roots |
| Inject `DbContext` in application/domain services | Use `IRepository<T>` or custom repository interface |
| Use `DbContext` directly outside the EF Core project | Access via `GetDbContextAsync()` inside repository only |
## DbContext Configuration
```csharp
[ConnectionStringName("Default")]
public class MyProjectDbContext : AbpDbContext<MyProjectDbContext>
{
public DbSet<Book> Books { get; set; }
public DbSet<Author> Authors { get; set; }
public MyProjectDbContext(DbContextOptions<MyProjectDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// Configure all entities
builder.ConfigureMyProject();
}
}
```
## Entity Configuration
```csharp
public static class MyProjectDbContextModelCreatingExtensions
{
public static void ConfigureMyProject(this ModelBuilder builder)
{
Check.NotNull(builder, nameof(builder));
builder.Entity<Book>(b =>
{
b.ToTable(MyProjectConsts.DbTablePrefix + "Books", MyProjectConsts.DbSchema);
b.ConfigureByConvention(); // ABP conventions (audit, soft-delete, etc.)
// Property configurations
b.Property(x => x.Name)
.IsRequired()
.HasMaxLength(BookConsts.MaxNameLength);
b.Property(x => x.Price)
.HasColumnType("decimal(18,2)");
// Indexes
b.HasIndex(x => x.Name);
// Relationships
b.HasOne<Author>()
.WithMany()
.HasForeignKey(x => x.AuthorId)
.OnDelete(DeleteBehavior.Restrict);
});
}
}
```
## Repository Implementation
```csharp
public class BookRepository : EfCoreRepository<MyProjectDbContext, Book, Guid>, IBookRepository
{
public BookRepository(IDbContextProvider<MyProjectDbContext> dbContextProvider)
: base(dbContextProvider)
{
}
public async Task<Book> FindByNameAsync(
string name,
bool includeDetails = true,
CancellationToken cancellationToken = default)
{
var dbSet = await GetDbSetAsync();
return await dbSet
.IncludeDetails(includeDetails)
.FirstOrDefaultAsync(
b => b.Name == name,
GetCancellationToken(cancellationToken));
}
public async Task<List<Book>> GetListByAuthorAsync(
Guid authorId,
bool includeDetails = false,
CancellationToken cancellationToken = default)
{
var dbSet = await GetDbSetAsync();
return await dbSet
.IncludeDetails(includeDetails)
.Where(b => b.AuthorId == authorId)
.ToListAsync(GetCancellationToken(cancellationToken));
}
public override async Task<IQueryable<Book>> WithDetailsAsync()
{
return (await GetQueryableAsync())
.Include(b => b.Reviews);
}
}
```
## Extension Method for Include
```csharp
public static class BookEfCoreQueryableExtensions
{
public static IQueryable<Book> IncludeDetails(
this IQueryable<Book> queryable,
bool include = true)
{
if (!include)
{
return queryable;
}
return queryable
.Include(b => b.Reviews);
}
}
```
## Migration Commands
```bash
# Navigate to EF Core project
cd src/MyProject.EntityFrameworkCore
# Add migration
dotnet ef migrations add MigrationName
# Apply migration (choose one):
dotnet run --project ../MyProject.DbMigrator # Recommended - also seeds data
dotnet ef database update # EF Core command only
# Remove last migration (if not applied)
dotnet ef migrations remove
# Generate SQL script
dotnet ef migrations script
```
> **Note**: ABP templates include `IDesignTimeDbContextFactory` in the EF Core project, so `-s` (startup project) parameter is not needed.
## Module Configuration
```csharp
[DependsOn(typeof(AbpEntityFrameworkCoreModule))]
public class MyProjectEntityFrameworkCoreModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddAbpDbContext<MyProjectDbContext>(options =>
{
// Add default repositories for aggregate roots only (DDD best practice)
options.AddDefaultRepositories();
// ⚠️ Avoid includeAllEntities: true - it creates repositories for child entities,
// allowing them to be modified without going through the aggregate root,
// which breaks data consistency
});
Configure<AbpDbContextOptions>(options =>
{
options.UseSqlServer(); // or UseNpgsql(), UseMySql(), etc.
});
}
}
```
## Best Practices
### Repositories for Aggregate Roots Only
Don't use `includeAllEntities: true` in `AddDefaultRepositories()`. This creates repositories for child entities, allowing direct modification without going through the aggregate root - breaking DDD data consistency rules.
```csharp
// ✅ Correct - Only aggregate roots get repositories
options.AddDefaultRepositories();
// ❌ Avoid - Creates repositories for ALL entities including child entities
options.AddDefaultRepositories(includeAllEntities: true);
```
### Always Call ConfigureByConvention
```csharp
builder.Entity<MyEntity>(b =>
{
b.ConfigureByConvention(); // Don't forget this!
// Other configurations...
});
```
### Use Table Prefix
```csharp
public static class MyProjectConsts
{
public const string DbTablePrefix = "App";
public const string DbSchema = null; // Or "myschema"
}
```
### Performance Tips
- Add explicit indexes for frequently queried fields
- Use `AsNoTracking()` for read-only queries
- Avoid N+1 queries with `.Include()` or specifications
- ABP handles cancellation automatically; use `GetCancellationToken(cancellationToken)` only in custom repository methods
- Consider query splitting for complex queries with multiple collections
### Accessing Raw DbContext
```csharp
public async Task CustomOperationAsync()
{
var dbContext = await GetDbContextAsync();
// Raw SQL
await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE Books SET IsPublished = 1 WHERE AuthorId = {0}",
authorId
);
}
```
## Data Seeding
```csharp
public class MyProjectDataSeedContributor : IDataSeedContributor, ITransientDependency
{
private readonly IRepository<Book, Guid> _bookRepository;
private readonly IGuidGenerator _guidGenerator;
public async Task SeedAsync(DataSeedContext context)
{
if (await _bookRepository.GetCountAsync() > 0)
{
return;
}
await _bookRepository.InsertAsync(
new Book(_guidGenerator.Create(), "Sample Book", 19.99m, Guid.Empty),
autoSave: true
);
}
}
```

243
.claude/skills/abp-infrastructure/SKILL.md

@ -0,0 +1,243 @@
---
name: abp-infrastructure
description: ABP infrastructure services - ISettingProvider, IFeatureChecker, IDistributedCache, ILocalEventBus, IDistributedEventBus, IBackgroundJobManager, localization resource. Use when working with settings, feature flags, caching, event bus, or background jobs in ABP.
---
# ABP Infrastructure Services
> **Docs**: https://abp.io/docs/latest/framework/infrastructure
## Settings
### Define Settings
```csharp
public class MySettingDefinitionProvider : SettingDefinitionProvider
{
public override void Define(ISettingDefinitionContext context)
{
context.Add(
new SettingDefinition("MyApp.MaxItemCount", "10"),
new SettingDefinition("MyApp.EnableFeature", "false"),
new SettingDefinition("MyApp.SecretKey", isEncrypted: true)
);
}
}
```
### Read Settings
```csharp
public class MyService : ITransientDependency
{
private readonly ISettingProvider _settingProvider;
public async Task DoSomethingAsync()
{
var maxCount = await _settingProvider.GetAsync<int>("MyApp.MaxItemCount");
var isEnabled = await _settingProvider.IsTrueAsync("MyApp.EnableFeature");
}
}
```
### Setting Value Providers (Priority Order)
1. User settings (highest)
2. Tenant settings
3. Global settings
4. Configuration (appsettings.json)
5. Default value (lowest)
## Features
### Define Features
```csharp
public class MyFeatureDefinitionProvider : FeatureDefinitionProvider
{
public override void Define(IFeatureDefinitionContext context)
{
var myGroup = context.AddGroup("MyApp");
myGroup.AddFeature(
"MyApp.PdfReporting",
defaultValue: "false",
valueType: new ToggleStringValueType()
);
myGroup.AddFeature(
"MyApp.MaxProductCount",
defaultValue: "10",
valueType: new FreeTextStringValueType(new NumericValueValidator(1, 1000))
);
}
}
```
### Check Features
```csharp
[RequiresFeature("MyApp.PdfReporting")]
public async Task<PdfReportDto> GetPdfReportAsync()
{
// Only executes if feature is enabled
}
// Or programmatically
if (await _featureChecker.IsEnabledAsync("MyApp.PdfReporting"))
{
// Feature is enabled for current tenant
}
var maxCount = await _featureChecker.GetAsync<int>("MyApp.MaxProductCount");
```
## Distributed Caching
### Typed Cache
```csharp
public class BookService : ITransientDependency
{
private readonly IDistributedCache<BookCacheItem> _cache;
private readonly IClock _clock;
public BookService(IDistributedCache<BookCacheItem> cache, IClock clock)
{
_cache = cache;
_clock = clock;
}
public async Task<BookCacheItem> GetAsync(Guid bookId)
{
return await _cache.GetOrAddAsync(
bookId.ToString(),
async () => await GetBookFromDatabaseAsync(bookId),
() => new DistributedCacheEntryOptions
{
AbsoluteExpiration = _clock.Now.AddHours(1)
}
);
}
}
[CacheName("Books")]
public class BookCacheItem
{
public string Name { get; set; }
public decimal Price { get; set; }
}
```
## Event Bus
### Local Events (Same Process)
```csharp
// Event class
public class OrderCreatedEvent
{
public Order Order { get; set; }
}
// Handler
public class OrderCreatedEventHandler : ILocalEventHandler<OrderCreatedEvent>, ITransientDependency
{
public async Task HandleEventAsync(OrderCreatedEvent eventData)
{
// Handle within same transaction
}
}
// Publish
await _localEventBus.PublishAsync(new OrderCreatedEvent { Order = order });
```
### Distributed Events (Cross-Service)
```csharp
// Event Transfer Object (in Domain.Shared)
[EventName("MyApp.Order.Created")]
public class OrderCreatedEto
{
public Guid OrderId { get; set; }
public string OrderNumber { get; set; }
}
// Handler
public class OrderCreatedEtoHandler : IDistributedEventHandler<OrderCreatedEto>, ITransientDependency
{
public async Task HandleEventAsync(OrderCreatedEto eventData)
{
// Handle distributed event
}
}
// Publish
await _distributedEventBus.PublishAsync(new OrderCreatedEto { ... });
```
### When to Use Which
- **Local**: Within same module/bounded context
- **Distributed**: Cross-module or microservice communication
## Background Jobs
### Define Job
```csharp
public class EmailSendingArgs
{
public string EmailAddress { get; set; }
public string Subject { get; set; }
public string Body { get; set; }
}
public class EmailSendingJob : AsyncBackgroundJob<EmailSendingArgs>, ITransientDependency
{
private readonly IEmailSender _emailSender;
public EmailSendingJob(IEmailSender emailSender)
{
_emailSender = emailSender;
}
public override async Task ExecuteAsync(EmailSendingArgs args)
{
await _emailSender.SendAsync(args.EmailAddress, args.Subject, args.Body);
}
}
```
### Enqueue Job
```csharp
await _backgroundJobManager.EnqueueAsync(
new EmailSendingArgs
{
EmailAddress = "user@example.com",
Subject = "Hello",
Body = "..."
},
delay: TimeSpan.FromMinutes(5) // Optional delay
);
```
## Localization
### Define Resource
```csharp
[LocalizationResourceName("MyModule")]
public class MyModuleResource { }
```
### JSON Structure
```json
{
"culture": "en",
"texts": {
"HelloWorld": "Hello World!",
"Menu:Books": "Books"
}
}
```
### Usage
- In `ApplicationService`: Use `L["Key"]` property (already available from base class)
- In other services: Inject `IStringLocalizer<MyResource>`
> **Tip**: ABP base classes already provide commonly used services as properties. Check before injecting:
> - `StringLocalizer` (L), `Clock`, `CurrentUser`, `CurrentTenant`, `GuidGenerator`
> - `AuthorizationService`, `FeatureChecker`, `DataFilter`
> - `LoggerFactory`, `Logger`
> - Methods like `CheckPolicyAsync()` for authorization checks

209
.claude/skills/abp-microservice/SKILL.md

@ -0,0 +1,209 @@
---
name: abp-microservice
description: ABP Microservice solution template - service structure, Integration Services ([IntegrationService]), inter-service HTTP proxies, distributed events with Outbox/Inbox, Entity Cache, RabbitMQ/Redis/YARP setup. Use when working with the ABP microservice solution template or inter-service communication patterns.
---
# ABP Microservice Solution Template
> **Docs**: https://abp.io/docs/latest/solution-templates/microservice
## Solution Structure
```
MyMicroservice/
├── apps/ # UI applications
│ ├── web/ # Web application
│ ├── public-web/ # Public website
│ └── auth-server/ # Authentication server (OpenIddict)
├── gateways/ # BFF pattern - one gateway per UI
│ └── web-gateway/ # YARP reverse proxy
├── services/ # Microservices
│ ├── administration/ # Permissions, settings, features
│ ├── identity/ # Users, roles
│ └── [your-services]/ # Your business services
└── etc/
├── docker/ # Docker compose for local infra
└── helm/ # Kubernetes deployment
```
## Microservice Structure (NOT Layered!)
Each microservice has simplified structure - everything in one project:
```
services/ordering/
├── OrderingService/ # Main project
│ ├── Entities/
│ ├── Services/
│ ├── IntegrationServices/ # For inter-service communication
│ ├── Data/ # DbContext (implements IHasEventInbox, IHasEventOutbox)
│ └── OrderingServiceModule.cs
├── OrderingService.Contracts/ # Interfaces, DTOs, ETOs (shared)
└── OrderingService.Tests/
```
## Inter-Service Communication
### 1. Integration Services (Synchronous HTTP)
For synchronous calls, use **Integration Services** - NOT regular application services.
#### Step 1: Provider Service - Create Integration Service
```csharp
// In CatalogService.Contracts project
[IntegrationService]
public interface IProductIntegrationService : IApplicationService
{
Task<List<ProductDto>> GetProductsByIdsAsync(List<Guid> ids);
}
// In CatalogService project
[IntegrationService]
public class ProductIntegrationService : ApplicationService, IProductIntegrationService
{
public async Task<List<ProductDto>> GetProductsByIdsAsync(List<Guid> ids)
{
var products = await _productRepository.GetListAsync(p => ids.Contains(p.Id));
return ObjectMapper.Map<List<Product>, List<ProductDto>>(products);
}
}
```
#### Step 2: Provider Service - Expose Integration Services
```csharp
// In CatalogServiceModule.cs
Configure<AbpAspNetCoreMvcOptions>(options =>
{
options.ExposeIntegrationServices = true;
});
```
#### Step 3: Consumer Service - Add Package Reference
Add reference to provider's Contracts project (via ABP Studio or manually):
- Right-click OrderingService → Add Package Reference → Select `CatalogService.Contracts`
#### Step 4: Consumer Service - Generate Proxies
```bash
# Run ABP CLI in consumer service folder
abp generate-proxy -t csharp -u http://localhost:44361 -m catalog --without-contracts
```
Or use ABP Studio: Right-click service → ABP CLI → Generate Proxy → C#
#### Step 5: Consumer Service - Register HTTP Client Proxies
```csharp
// In OrderingServiceModule.cs
[DependsOn(typeof(CatalogServiceContractsModule))] // Add module dependency
public class OrderingServiceModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
// Register static HTTP client proxies
context.Services.AddStaticHttpClientProxies(
typeof(CatalogServiceContractsModule).Assembly,
"CatalogService");
}
}
```
#### Step 6: Consumer Service - Configure Remote Service URL
```json
// appsettings.json
"RemoteServices": {
"CatalogService": {
"BaseUrl": "http://localhost:44361"
}
}
```
#### Step 7: Use Integration Service
```csharp
public class OrderAppService : ApplicationService
{
private readonly IProductIntegrationService _productIntegrationService;
public async Task<List<OrderDto>> GetListAsync()
{
var orders = await _orderRepository.GetListAsync();
var productIds = orders.Select(o => o.ProductId).Distinct().ToList();
// Call remote service via generated proxy
var products = await _productIntegrationService.GetProductsByIdsAsync(productIds);
// ...
}
}
```
> **Why Integration Services?** Application services are for UI - they have different authorization, validation, and optimization needs. Integration services are designed specifically for inter-service communication.
**When to use:** Need immediate response, data required to complete current operation (e.g., get product details to display in order list).
### 2. Distributed Events (Asynchronous)
Use RabbitMQ-based events for loose coupling.
**When to use:**
- Notifying other services about state changes (e.g., "order placed", "stock updated")
- Operations that don't need immediate response
- When services should remain independent and decoupled
```csharp
// Define ETO in Contracts project
[EventName("Product.StockChanged")]
public class StockCountChangedEto
{
public Guid ProductId { get; set; }
public int NewCount { get; set; }
}
// Publish
await _distributedEventBus.PublishAsync(new StockCountChangedEto { ... });
// Subscribe in another service
public class StockChangedHandler : IDistributedEventHandler<StockCountChangedEto>, ITransientDependency
{
public async Task HandleEventAsync(StockCountChangedEto eventData) { ... }
}
```
DbContext must implement `IHasEventInbox`, `IHasEventOutbox` for Outbox/Inbox pattern.
## Performance: Entity Cache
For frequently accessed data from other services, use Entity Cache:
```csharp
// Register
context.Services.AddEntityCache<Product, ProductDto, Guid>();
// Use - auto-invalidates on entity changes
private readonly IEntityCache<ProductDto, Guid> _productCache;
public async Task<ProductDto> GetProductAsync(Guid id)
{
return await _productCache.GetAsync(id);
}
```
## Pre-Configured Infrastructure
- **RabbitMQ** - Distributed events with Outbox/Inbox
- **Redis** - Distributed cache and locking
- **YARP** - API Gateway
- **OpenIddict** - Auth server
## Best Practices
- **Choose communication wisely** - Synchronous for queries needing immediate data, asynchronous for notifications and state changes
- **Use Integration Services** - Not application services for inter-service calls
- **Cache remote data** - Use Entity Cache or IDistributedCache for frequently accessed data
- **Share only Contracts** - Never share implementations
- **Idempotent handlers** - Events may be delivered multiple times
- **Database per service** - Each service owns its database

234
.claude/skills/abp-module/SKILL.md

@ -0,0 +1,234 @@
---
name: abp-module
description: ABP reusable Module solution template - EF Core + MongoDB dual support, virtual methods for extensibility, DbTablePrefix, module options pattern, entity extension, separate connection string. Use when building or reviewing reusable ABP modules that will be distributed or consumed by other solutions.
---
# ABP Module Solution Template
> **Docs**: https://abp.io/docs/latest/solution-templates/application-module
This template is for developing reusable ABP modules. Key requirement: **extensibility** - consumers must be able to override and customize module behavior.
## Solution Structure
```
MyModule/
├── src/
│ ├── MyModule.Domain.Shared/ # Constants, enums, localization
│ ├── MyModule.Domain/ # Entities, repository interfaces, domain services
│ ├── MyModule.Application.Contracts/ # DTOs, service interfaces
│ ├── MyModule.Application/ # Service implementations
│ ├── MyModule.EntityFrameworkCore/ # EF Core implementation
│ ├── MyModule.MongoDB/ # MongoDB implementation
│ ├── MyModule.HttpApi/ # REST controllers
│ ├── MyModule.HttpApi.Client/ # Client proxies
│ ├── MyModule.Web/ # MVC/Razor Pages UI
│ └── MyModule.Blazor/ # Blazor UI
├── test/
│ └── MyModule.Tests/
└── host/
└── MyModule.HttpApi.Host/ # Test host application
```
## Database Independence
Support both EF Core and MongoDB:
### Repository Interface (Domain)
```csharp
public interface IBookRepository : IRepository<Book, Guid>
{
Task<Book> FindByNameAsync(string name);
Task<List<Book>> GetListByAuthorAsync(Guid authorId);
}
```
### EF Core Implementation
```csharp
public class BookRepository : EfCoreRepository<MyModuleDbContext, Book, Guid>, IBookRepository
{
public async Task<Book> FindByNameAsync(string name)
{
var dbSet = await GetDbSetAsync();
return await dbSet.FirstOrDefaultAsync(b => b.Name == name);
}
}
```
### MongoDB Implementation
```csharp
public class BookRepository : MongoDbRepository<MyModuleMongoDbContext, Book, Guid>, IBookRepository
{
public async Task<Book> FindByNameAsync(string name)
{
var queryable = await GetQueryableAsync();
return await queryable.FirstOrDefaultAsync(b => b.Name == name);
}
}
```
## Table/Collection Prefix
Allow customization to avoid naming conflicts:
```csharp
// Domain.Shared
public static class MyModuleDbProperties
{
public static string DbTablePrefix { get; set; } = "MyModule";
public static string DbSchema { get; set; } = null;
public const string ConnectionStringName = "MyModule";
}
```
Usage:
```csharp
builder.Entity<Book>(b =>
{
b.ToTable(MyModuleDbProperties.DbTablePrefix + "Books", MyModuleDbProperties.DbSchema);
});
```
## Module Options
Provide configuration options:
```csharp
// Domain
public class MyModuleOptions
{
public bool EnableFeatureX { get; set; } = true;
public int MaxItemCount { get; set; } = 100;
}
```
Usage in module:
```csharp
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<MyModuleOptions>(options =>
{
options.EnableFeatureX = true;
});
}
```
Usage in service:
```csharp
public class MyService : ITransientDependency
{
private readonly MyModuleOptions _options;
public MyService(IOptions<MyModuleOptions> options)
{
_options = options.Value;
}
}
```
## Extensibility Points
### Virtual Methods (Critical for Modules!)
When developing a reusable module, **all public and protected methods must be virtual** to allow consumers to override behavior:
```csharp
public class BookAppService : ApplicationService, IBookAppService
{
// ✅ Public methods MUST be virtual
public virtual async Task<BookDto> CreateAsync(CreateBookDto input)
{
var book = await CreateBookEntityAsync(input);
await _bookRepository.InsertAsync(book);
return _bookMapper.MapToDto(book);
}
// ✅ Use protected virtual for helper methods (not private)
protected virtual Task<Book> CreateBookEntityAsync(CreateBookDto input)
{
return Task.FromResult(new Book(
GuidGenerator.Create(),
input.Name,
input.Price
));
}
// ❌ WRONG for modules - private methods cannot be overridden
// private Book CreateBook(CreateBookDto input) { ... }
}
```
This allows module consumers to:
- Override specific methods without copying entire class
- Extend functionality while preserving base behavior
- Customize module behavior for their needs
### Entity Extension
Support object extension system:
```csharp
public class MyModuleModuleExtensionConfigurator
{
public static void Configure()
{
OneTimeRunner.Run(() =>
{
ObjectExtensionManager.Instance.Modules()
.ConfigureMyModule(module =>
{
module.ConfigureBook(book =>
{
book.AddOrUpdateProperty<string>("CustomProperty");
});
});
});
}
}
```
## Localization
```csharp
// Domain.Shared
[LocalizationResourceName("MyModule")]
public class MyModuleResource
{
}
// Module configuration
Configure<AbpLocalizationOptions>(options =>
{
options.Resources
.Add<MyModuleResource>("en")
.AddVirtualJson("/Localization/MyModule");
});
```
## Permission Definition
```csharp
public class MyModulePermissionDefinitionProvider : PermissionDefinitionProvider
{
public override void Define(IPermissionDefinitionContext context)
{
var myGroup = context.AddGroup(
MyModulePermissions.GroupName,
L("Permission:MyModule"));
myGroup.AddPermission(
MyModulePermissions.Books.Default,
L("Permission:Books"));
}
}
```
## Best Practices
1. **Virtual methods** - All public/protected methods must be `virtual` for extensibility
2. **Protected virtual helpers** - Use `protected virtual` instead of `private` for helper methods
3. **Database agnostic** - Support both EF Core and MongoDB
4. **Configurable** - Use options pattern for customization
5. **Localizable** - Use localization for all user-facing text
6. **Table prefix** - Allow customization to avoid conflicts
7. **Separate connection string** - Support dedicated database
8. **No dependencies on host** - Module should be self-contained
9. **Test with host app** - Include a host application for testing

202
.claude/skills/abp-mongodb/SKILL.md

@ -0,0 +1,202 @@
---
name: abp-mongodb
description: ABP MongoDB patterns - AbpMongoDbContext, IMongoCollection, MongoDbRepository, no migrations, embedded documents vs references, manual UpdateAsync required. Use when working in MongoDB projects or implementing MongoDB repositories in ABP.
---
# ABP MongoDB
> **Docs**: https://abp.io/docs/latest/framework/data/mongodb
## MongoDbContext Configuration
```csharp
[ConnectionStringName("Default")]
public class MyProjectMongoDbContext : AbpMongoDbContext
{
public IMongoCollection<Book> Books => Collection<Book>();
public IMongoCollection<Author> Authors => Collection<Author>();
protected override void CreateModel(IMongoModelBuilder modelBuilder)
{
base.CreateModel(modelBuilder);
modelBuilder.ConfigureMyProject();
}
}
```
## Entity Configuration
```csharp
public static class MyProjectMongoDbContextExtensions
{
public static void ConfigureMyProject(this IMongoModelBuilder builder)
{
Check.NotNull(builder, nameof(builder));
builder.Entity<Book>(b =>
{
b.CollectionName = MyProjectConsts.DbTablePrefix + "Books";
});
builder.Entity<Author>(b =>
{
b.CollectionName = MyProjectConsts.DbTablePrefix + "Authors";
});
}
}
```
## Repository Implementation
```csharp
public class BookRepository : MongoDbRepository<MyProjectMongoDbContext, Book, Guid>, IBookRepository
{
public BookRepository(IMongoDbContextProvider<MyProjectMongoDbContext> dbContextProvider)
: base(dbContextProvider)
{
}
public async Task<Book> FindByNameAsync(
string name,
bool includeDetails = true,
CancellationToken cancellationToken = default)
{
return await (await GetQueryableAsync())
.FirstOrDefaultAsync(
b => b.Name == name,
GetCancellationToken(cancellationToken));
}
public async Task<List<Book>> GetListByAuthorAsync(
Guid authorId,
bool includeDetails = false,
CancellationToken cancellationToken = default)
{
return await (await GetQueryableAsync())
.Where(b => b.AuthorId == authorId)
.ToListAsync(GetCancellationToken(cancellationToken));
}
}
```
## Module Configuration
```csharp
[DependsOn(typeof(AbpMongoDbModule))]
public class MyProjectMongoDbModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddMongoDbContext<MyProjectMongoDbContext>(options =>
{
// Add default repositories for aggregate roots only (DDD best practice)
options.AddDefaultRepositories();
// ⚠️ Avoid includeAllEntities: true - breaks DDD data consistency
});
}
}
```
## Connection String
In `appsettings.json`:
```json
{
"ConnectionStrings": {
"Default": "mongodb://localhost:27017/MyProjectDb"
}
}
```
## Key Differences from EF Core
### No Migrations
MongoDB is schema-less; no migrations needed. Changes to entity structure are handled automatically.
### includeDetails Parameter
Often ignored in MongoDB because documents typically embed related data:
```csharp
public async Task<List<Book>> GetListAsync(
bool includeDetails = false, // Usually ignored
CancellationToken cancellationToken = default)
{
// MongoDB documents already include nested data
return await (await GetQueryableAsync())
.ToListAsync(GetCancellationToken(cancellationToken));
}
```
### Embedded Documents vs References
```csharp
// Embedded (stored in same document)
public class Order : AggregateRoot<Guid>
{
public List<OrderLine> Lines { get; set; } // Embedded
}
// Reference (separate collection, store ID only)
public class Order : AggregateRoot<Guid>
{
public Guid CustomerId { get; set; } // Reference by ID
}
```
### No Change Tracking
MongoDB doesn't track entity changes automatically:
```csharp
public async Task UpdateBookAsync(Guid id, string newName)
{
var book = await _bookRepository.GetAsync(id);
book.SetName(newName);
// Must explicitly update
await _bookRepository.UpdateAsync(book);
}
```
## Direct Collection Access
```csharp
public async Task CustomOperationAsync()
{
var collection = await GetCollectionAsync();
// Use MongoDB driver directly
var filter = Builders<Book>.Filter.Eq(b => b.AuthorId, authorId);
var update = Builders<Book>.Update.Set(b => b.IsPublished, true);
await collection.UpdateManyAsync(filter, update);
}
```
## Indexing
Configure indexes in repository or via MongoDB driver:
```csharp
public class BookRepository : MongoDbRepository<MyProjectMongoDbContext, Book, Guid>, IBookRepository
{
public override async Task<IQueryable<Book>> GetQueryableAsync()
{
var collection = await GetCollectionAsync();
// Ensure index exists
var indexKeys = Builders<Book>.IndexKeys.Ascending(b => b.Name);
await collection.Indexes.CreateOneAsync(new CreateIndexModel<Book>(indexKeys));
return await base.GetQueryableAsync();
}
}
```
## Best Practices
- Design documents for query patterns (denormalize when needed)
- Use references for frequently changing data
- Use embedding for data that's always accessed together
- Add indexes for frequently queried fields
- Use `GetCancellationToken(cancellationToken)` for proper cancellation
- Remember: ABP data filters (soft-delete, multi-tenancy) work with MongoDB too

161
.claude/skills/abp-multi-tenancy/SKILL.md

@ -0,0 +1,161 @@
---
name: abp-multi-tenancy
description: ABP Multi-Tenancy - IMultiTenant interface, CurrentTenant, CurrentTenant.Change(), DataFilter.Disable(IMultiTenant), tenant resolution order, database-per-tenant. Use when working with multi-tenant features, tenant-specific data isolation, or switching tenant context.
---
# ABP Multi-Tenancy
> **Docs**: https://abp.io/docs/latest/framework/architecture/multi-tenancy
## Making Entities Multi-Tenant
Implement `IMultiTenant` interface to make entities tenant-aware:
```csharp
public class Product : AggregateRoot<Guid>, IMultiTenant
{
public Guid? TenantId { get; set; } // Required by IMultiTenant
public string Name { get; private set; }
public decimal Price { get; private set; }
protected Product() { }
public Product(Guid id, string name, decimal price) : base(id)
{
Name = name;
Price = price;
// TenantId is automatically set from CurrentTenant.Id
}
}
```
**Key points:**
- `TenantId` is **nullable** - `null` means entity belongs to Host
- ABP **automatically filters** queries by current tenant
- ABP **automatically sets** `TenantId` when creating entities
## Accessing Current Tenant
Use `CurrentTenant` property (available in base classes) or inject `ICurrentTenant`:
```csharp
public class ProductAppService : ApplicationService
{
public async Task DoSomethingAsync()
{
// Available from base class
var tenantId = CurrentTenant.Id; // Guid? - null for host
var tenantName = CurrentTenant.Name; // string?
var isAvailable = CurrentTenant.IsAvailable; // true if Id is not null
}
}
// In other services
public class MyService : ITransientDependency
{
private readonly ICurrentTenant _currentTenant;
public MyService(ICurrentTenant currentTenant) => _currentTenant = currentTenant;
}
```
## Switching Tenant Context
Use `CurrentTenant.Change()` to temporarily switch tenant (useful in host context):
```csharp
public class ProductManager : DomainService
{
private readonly IRepository<Product, Guid> _productRepository;
public async Task<long> GetProductCountAsync(Guid? tenantId)
{
// Switch to specific tenant
using (CurrentTenant.Change(tenantId))
{
return await _productRepository.GetCountAsync();
}
// Automatically restored to previous tenant after using block
}
public async Task DoHostOperationAsync()
{
// Switch to host context
using (CurrentTenant.Change(null))
{
// Operations here are in host context
}
}
}
```
> **Important**: Always use `Change()` with a `using` statement.
## Disabling Multi-Tenant Filter
To query all tenants' data (only works with single database):
```csharp
public class ProductManager : DomainService
{
public async Task<long> GetAllProductCountAsync()
{
// DataFilter is available from base class
using (DataFilter.Disable<IMultiTenant>())
{
return await _productRepository.GetCountAsync();
// Returns count from ALL tenants
}
}
}
```
> **Note**: This doesn't work with separate databases per tenant.
## Database Architecture Options
| Approach | Description | Use Case |
|----------|-------------|----------|
| Single Database | All tenants share one database | Simple, cost-effective |
| Database per Tenant | Each tenant has dedicated database | Data isolation, compliance |
| Hybrid | Mix of shared and dedicated | Flexible, premium tenants |
Connection strings are configured per tenant in Tenant Management module.
## Best Practices
1. **Always implement `IMultiTenant`** for tenant-specific entities
2. **Never manually filter by `TenantId`** - ABP does it automatically
3. **Don't change `TenantId` after creation** - it moves entity between tenants
4. **Use `Change()` scope carefully** - nested scopes are supported
5. **Test both host and tenant contexts** - ensure proper data isolation
6. **Consider nullable `TenantId`** - entity may be host-only or shared
## Enabling Multi-Tenancy
```csharp
Configure<AbpMultiTenancyOptions>(options =>
{
options.IsEnabled = true; // Enabled by default in ABP templates
});
```
Check `MultiTenancyConsts.IsEnabled` in your solution for centralized control.
## Tenant Resolution
ABP resolves current tenant from (in order):
1. Current user's claims
2. Query string (`?__tenant=...`)
3. Route (`/{__tenant}/...`)
4. HTTP header (`__tenant`)
5. Cookie (`__tenant`)
6. Domain/subdomain (if configured)
For subdomain-based resolution:
```csharp
Configure<AbpTenantResolveOptions>(options =>
{
options.AddDomainTenantResolver("{0}.mydomain.com");
});
```

257
.claude/skills/abp-mvc/SKILL.md

@ -0,0 +1,257 @@
---
name: abp-mvc
description: ABP MVC and Razor Pages UI - AbpPageModel, abp tag helpers (abp-card, abp-dynamic-form, abp-modal), JavaScript abp.ajax/abp.auth/abp.notify, DataTables integration, bundle/minification. Use when working on MVC or Razor Pages UI in ABP projects.
---
# ABP MVC / Razor Pages UI
> **Docs**: https://abp.io/docs/latest/framework/ui/mvc-razor-pages/overall
## Razor Page Model
```csharp
public class IndexModel : AbpPageModel
{
private readonly IBookAppService _bookAppService;
public List<BookDto> Books { get; set; }
public IndexModel(IBookAppService bookAppService)
{
_bookAppService = bookAppService;
}
public async Task OnGetAsync()
{
var result = await _bookAppService.GetListAsync(
new PagedAndSortedResultRequestDto()
);
Books = result.Items.ToList();
}
}
```
## Razor Page View
```html
@page
@model IndexModel
<abp-card>
<abp-card-header>
<abp-row>
<abp-column size-md="_6">
<h2>@L["Books"]</h2>
</abp-column>
<abp-column size-md="_6" class="text-end">
<abp-button button-type="Primary"
id="NewBookButton"
text="@L["NewBook"].Value" />
</abp-column>
</abp-row>
</abp-card-header>
<abp-card-body>
<abp-table striped-rows="true" id="BooksTable">
<thead>
<tr>
<th>@L["Name"]</th>
<th>@L["Price"]</th>
<th>@L["Actions"]</th>
</tr>
</thead>
<tbody>
@foreach (var book in Model.Books)
{
<tr>
<td>@book.Name</td>
<td>@book.Price</td>
<td>
<abp-button button-type="Primary" size="Small"
text="@L["Edit"].Value" />
</td>
</tr>
}
</tbody>
</abp-table>
</abp-card-body>
</abp-card>
```
## ABP Tag Helpers
### Cards
```html
<abp-card>
<abp-card-header>Header</abp-card-header>
<abp-card-body>Content</abp-card-body>
<abp-card-footer>Footer</abp-card-footer>
</abp-card>
```
### Buttons
```html
<abp-button button-type="Primary" text="@L["Save"].Value" />
<abp-button button-type="Danger" icon="fa fa-trash" />
```
### Forms
```html
<abp-dynamic-form abp-model="Book" asp-page="/Books/CreateModal">
<abp-modal>
<abp-modal-header title="@L["NewBook"].Value" />
<abp-modal-body>
<abp-form-content />
</abp-modal-body>
<abp-modal-footer buttons="@(AbpModalButtons.Save | AbpModalButtons.Cancel)" />
</abp-modal>
</abp-dynamic-form>
```
### Tables
```html
<abp-table striped-rows="true" hoverable-rows="true">
<!-- content -->
</abp-table>
```
## Localization
```html
@* In Razor views/pages *@
<h1>@L["Books"]</h1>
@* With parameters *@
<p>@L["WelcomeMessage", Model.UserName]</p>
```
## JavaScript API
```javascript
// Localization
var text = abp.localization.getResource('BookStore')('Books');
// Authorization
if (abp.auth.isGranted('BookStore.Books.Create')) {
// Show create button
}
// Settings
var maxCount = abp.setting.get('BookStore.MaxItemCount');
// Ajax with automatic error handling
abp.ajax({
url: '/api/app/book',
type: 'POST',
data: JSON.stringify(bookData)
}).then(function(result) {
// Success
});
// Notifications
abp.notify.success('Book created successfully!');
abp.notify.error('An error occurred!');
// Confirmation
abp.message.confirm('Are you sure?').then(function(confirmed) {
if (confirmed) {
// User confirmed
}
});
```
## DataTables Integration
```javascript
var dataTable = $('#BooksTable').DataTable(
abp.libs.datatables.normalizeConfiguration({
serverSide: true,
paging: true,
ajax: abp.libs.datatables.createAjax(bookService.getList),
columnDefs: [
{
title: l('Name'),
data: 'name'
},
{
title: l('Price'),
data: 'price',
render: function(data) {
return data.toFixed(2);
}
},
{
title: l('Actions'),
rowAction: {
items: [
{
text: l('Edit'),
visible: abp.auth.isGranted('BookStore.Books.Edit'),
action: function(data) {
editModal.open({ id: data.record.id });
}
},
{
text: l('Delete'),
visible: abp.auth.isGranted('BookStore.Books.Delete'),
confirmMessage: function(data) {
return l('BookDeletionConfirmationMessage', data.record.name);
},
action: function(data) {
bookService.delete(data.record.id).then(function() {
abp.notify.success(l('SuccessfullyDeleted'));
dataTable.ajax.reload();
});
}
}
]
}
}
]
})
);
```
## Modal Pages
**CreateModal.cshtml:**
```html
@page
@model CreateModalModel
<abp-dynamic-form abp-model="Book" asp-page="/Books/CreateModal">
<abp-modal>
<abp-modal-header title="@L["NewBook"].Value" />
<abp-modal-body>
<abp-form-content />
</abp-modal-body>
<abp-modal-footer buttons="@(AbpModalButtons.Save | AbpModalButtons.Cancel)" />
</abp-modal>
</abp-dynamic-form>
```
**CreateModal.cshtml.cs:**
```csharp
public class CreateModalModel : AbpPageModel
{
[BindProperty]
public CreateBookDto Book { get; set; }
private readonly IBookAppService _bookAppService;
public CreateModalModel(IBookAppService bookAppService)
{
_bookAppService = bookAppService;
}
public async Task<IActionResult> OnPostAsync()
{
await _bookAppService.CreateAsync(Book);
return NoContent();
}
}
```
## Bundle & Minification
```csharp
Configure<AbpBundlingOptions>(options =>
{
options.StyleBundles.Configure(
StandardBundles.Styles.Global,
bundle => bundle.AddFiles("/styles/my-styles.css")
);
});
```

269
.claude/skills/abp-testing/SKILL.md

@ -0,0 +1,269 @@
---
name: abp-testing
description: ABP testing patterns - integration tests over unit tests, GetRequiredService, IDataSeedContributor, Shouldly assertions, AddAlwaysAllowAuthorization, NSubstitute mocking, WithUnitOfWorkAsync. Use when writing or reviewing tests for application services, domain services, or repositories in ABP projects.
---
# ABP Testing Patterns
> **Docs**: https://abp.io/docs/latest/testing
## Test Project Structure
| Project | Purpose | Base Class |
|---------|---------|------------|
| `*.Domain.Tests` | Domain logic, entities, domain services | `*DomainTestBase` |
| `*.Application.Tests` | Application services | `*ApplicationTestBase` |
| `*.EntityFrameworkCore.Tests` | Repository implementations | `*EntityFrameworkCoreTestBase` |
## Integration Test Approach
ABP recommends integration tests over unit tests:
- Tests run with real services and database (SQLite in-memory)
- No mocking of internal services
- Each test gets a fresh database instance
## Application Service Test
```csharp
public class BookAppService_Tests : MyProjectApplicationTestBase
{
private readonly IBookAppService _bookAppService;
public BookAppService_Tests()
{
_bookAppService = GetRequiredService<IBookAppService>();
}
[Fact]
public async Task Should_Get_List_Of_Books()
{
// Act
var result = await _bookAppService.GetListAsync(
new PagedAndSortedResultRequestDto()
);
// Assert
result.TotalCount.ShouldBeGreaterThan(0);
result.Items.ShouldContain(b => b.Name == "Test Book");
}
[Fact]
public async Task Should_Create_Book()
{
// Arrange
var input = new CreateBookDto
{
Name = "New Book",
Price = 19.99m
};
// Act
var result = await _bookAppService.CreateAsync(input);
// Assert
result.Id.ShouldNotBe(Guid.Empty);
result.Name.ShouldBe("New Book");
result.Price.ShouldBe(19.99m);
}
[Fact]
public async Task Should_Not_Create_Book_With_Invalid_Name()
{
// Arrange
var input = new CreateBookDto
{
Name = "", // Invalid
Price = 10m
};
// Act & Assert
await Should.ThrowAsync<AbpValidationException>(async () =>
{
await _bookAppService.CreateAsync(input);
});
}
}
```
## Domain Service Test
```csharp
public class BookManager_Tests : MyProjectDomainTestBase
{
private readonly BookManager _bookManager;
private readonly IBookRepository _bookRepository;
public BookManager_Tests()
{
_bookManager = GetRequiredService<BookManager>();
_bookRepository = GetRequiredService<IBookRepository>();
}
[Fact]
public async Task Should_Create_Book()
{
// Act
var book = await _bookManager.CreateAsync("Test Book", 29.99m);
// Assert
book.ShouldNotBeNull();
book.Name.ShouldBe("Test Book");
book.Price.ShouldBe(29.99m);
}
[Fact]
public async Task Should_Not_Allow_Duplicate_Book_Name()
{
// Arrange
await _bookManager.CreateAsync("Existing Book", 10m);
// Act & Assert
var exception = await Should.ThrowAsync<BusinessException>(async () =>
{
await _bookManager.CreateAsync("Existing Book", 20m);
});
exception.Code.ShouldBe("MyProject:BookNameAlreadyExists");
}
}
```
## Test Naming Convention
Use descriptive names:
```csharp
// Pattern: Should_ExpectedBehavior_When_Condition
public async Task Should_Create_Book_When_Input_Is_Valid()
public async Task Should_Throw_BusinessException_When_Name_Already_Exists()
public async Task Should_Return_Empty_List_When_No_Books_Exist()
```
## Arrange-Act-Assert (AAA)
```csharp
[Fact]
public async Task Should_Update_Book_Price()
{
// Arrange
var bookId = await CreateTestBookAsync();
var newPrice = 39.99m;
// Act
var result = await _bookAppService.UpdateAsync(bookId, new UpdateBookDto
{
Price = newPrice
});
// Assert
result.Price.ShouldBe(newPrice);
}
```
## Assertions with Shouldly
ABP uses Shouldly library:
```csharp
result.ShouldNotBeNull();
result.Name.ShouldBe("Expected Name");
result.Price.ShouldBeGreaterThan(0);
result.Items.ShouldContain(x => x.Id == expectedId);
result.Items.ShouldBeEmpty();
result.Items.Count.ShouldBe(5);
// Exception assertions
await Should.ThrowAsync<BusinessException>(async () =>
{
await _service.DoSomethingAsync();
});
var ex = await Should.ThrowAsync<BusinessException>(async () =>
{
await _service.DoSomethingAsync();
});
ex.Code.ShouldBe("MyProject:ErrorCode");
```
## Test Data Seeding
```csharp
public class MyProjectTestDataSeedContributor : IDataSeedContributor, ITransientDependency
{
public static readonly Guid TestBookId = Guid.Parse("...");
private readonly IBookRepository _bookRepository;
private readonly IGuidGenerator _guidGenerator;
public async Task SeedAsync(DataSeedContext context)
{
await _bookRepository.InsertAsync(
new Book(TestBookId, "Test Book", 19.99m, Guid.Empty),
autoSave: true
);
}
}
```
## Disabling Authorization in Tests
```csharp
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddAlwaysAllowAuthorization();
}
```
## Mocking External Services
Use NSubstitute when needed:
```csharp
public override void ConfigureServices(ServiceConfigurationContext context)
{
var emailSender = Substitute.For<IEmailSender>();
emailSender.SendAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(Task.CompletedTask);
context.Services.AddSingleton(emailSender);
}
```
## Testing with Specific User
```csharp
[Fact]
public async Task Should_Get_Current_User_Books()
{
// Login as specific user
await WithUnitOfWorkAsync(async () =>
{
using (CurrentUser.Change(TestData.UserId))
{
var result = await _bookAppService.GetMyBooksAsync();
result.Items.ShouldAllBe(b => b.CreatorId == TestData.UserId);
}
});
}
```
## Testing Multi-Tenancy
```csharp
[Fact]
public async Task Should_Filter_Books_By_Tenant()
{
using (CurrentTenant.Change(TestData.TenantId))
{
var result = await _bookAppService.GetListAsync(new GetBookListDto());
// Results should be filtered by tenant
}
}
```
## Best Practices
- Each test should be independent
- Don't share state between tests
- Use meaningful test data
- Test edge cases and error conditions
- Keep tests focused on single behavior
- Use test data seeders for common data
- Avoid testing framework internals

87
.github/scripts/test_update_dependency_changes.py

@ -14,7 +14,7 @@ import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from update_dependency_changes import merge_changes, render_section
from update_dependency_changes import merge_changes, render_section, normalize_version, extract_preamble
def test_update_then_revert():
@ -367,12 +367,83 @@ def test_document_format():
print("-" * 60 + "\n")
def test_extract_preamble_with_seo_block():
"""Test: content with a JSON SEO block before the heading."""
print("Test 17: extract_preamble - preamble before heading")
content = (
"```json\n"
"//[doc-seo]\n"
"{\n"
' "Description": "Some description."\n'
"}\n"
"```\n"
"\n"
"# Package Version Changes\n"
"\n"
"## 10.1.0-rc.1\n"
)
result = extract_preamble(content)
assert result == "```json\n//[doc-seo]\n{\n \"Description\": \"Some description.\"\n}\n```\n\n", \
f"Unexpected preamble: {repr(result)}"
print("✓ Passed: preamble correctly extracted\n")
def test_extract_preamble_no_preamble():
"""Test: heading at the very start — preamble should be empty string."""
print("Test 18: extract_preamble - no preamble before heading")
content = "# Package Version Changes\n\n## 10.1.0-rc.1\n"
result = extract_preamble(content)
assert result == "", f"Expected empty string, got: {repr(result)}"
print("✓ Passed: empty preamble returned when heading is at start\n")
def test_extract_preamble_no_heading():
"""Test: no matching heading — returns empty string."""
print("Test 19: extract_preamble - no matching heading")
content = "Some random content without the expected heading.\n"
result = extract_preamble(content)
assert result == "", f"Expected empty string, got: {repr(result)}"
print("✓ Passed: empty string returned when heading is absent\n")
def test_normalize_version_preview():
"""Test: preview suffix is normalized to rc.1."""
print("Test 20: normalize_version - preview -> rc.1")
assert normalize_version("10.1.0-preview") == "10.1.0-rc.1", \
f"Expected '10.1.0-rc.1', got: {normalize_version('10.1.0-preview')}"
assert normalize_version("10.2.0-preview") == "10.2.0-rc.1", \
f"Expected '10.2.0-rc.1', got: {normalize_version('10.2.0-preview')}"
print("✓ Passed: preview correctly normalized to rc.1\n")
def test_normalize_version_rc():
"""Test: rc.N versions are unchanged."""
print("Test 21: normalize_version - rc.N unchanged")
assert normalize_version("10.1.0-rc.1") == "10.1.0-rc.1", \
f"Expected '10.1.0-rc.1', got: {normalize_version('10.1.0-rc.1')}"
assert normalize_version("10.2.0-rc.1") == "10.2.0-rc.1", \
f"Expected '10.2.0-rc.1', got: {normalize_version('10.2.0-rc.1')}"
assert normalize_version("10.2.0-rc.2") == "10.2.0-rc.2", \
f"Expected '10.2.0-rc.2', got: {normalize_version('10.2.0-rc.2')}"
print("✓ Passed: rc.N versions unchanged\n")
def test_normalize_version_stable():
"""Test: stable versions are unchanged."""
print("Test 22: normalize_version - stable unchanged")
assert normalize_version("10.1.0") == "10.1.0", \
f"Expected '10.1.0', got: {normalize_version('10.1.0')}"
assert normalize_version("10.2.0") == "10.2.0", \
f"Expected '10.2.0', got: {normalize_version('10.2.0')}"
print("✓ Passed: stable versions unchanged\n")
def run_all_tests():
"""Run all test cases."""
print("=" * 70)
print("Testing update_dependency_changes.py")
print("=" * 70 + "\n")
test_update_then_revert()
test_add_then_remove_same_version()
test_remove_then_add_same_version()
@ -389,9 +460,15 @@ def run_all_tests():
test_add_add()
test_complex_chain_ending_in_original()
test_document_format()
test_extract_preamble_with_seo_block()
test_extract_preamble_no_preamble()
test_extract_preamble_no_heading()
test_normalize_version_preview()
test_normalize_version_rc()
test_normalize_version_stable()
print("=" * 70)
print("All 16 tests passed! ✓")
print("All 22 tests passed! ✓")
print("=" * 70)
print("\nTest coverage summary:")
print(" ✓ Basic scenarios (update, add, remove)")
@ -399,6 +476,8 @@ def run_all_tests():
print(" ✓ Complex multi-step sequences")
print(" ✓ Edge cases and duplicates")
print(" ✓ Document format validation")
print(" ✓ Preamble extraction (SEO block, no preamble, no heading)")
print(" ✓ Version normalization (preview -> rc.1)")
print("=" * 70)

21
.github/scripts/update_dependency_changes.py

@ -9,6 +9,22 @@ HEADER = "# Package Version Changes\n"
DOC_PATH = os.environ.get("DOC_PATH", "docs/en/package-version-changes.md")
def extract_preamble(content):
"""Extract content before the '# Package Version Changes' heading."""
header_pattern = re.compile(r"^# Package Version Changes\s*$", re.MULTILINE)
match = header_pattern.search(content)
if match:
return content[: match.start()]
return ""
def normalize_version(version):
"""Normalize version string: replace -preview suffix with -rc.1."""
if version and version.endswith("-preview"):
return version[: -len("-preview")] + "-rc.1"
return version
def get_version():
"""Read the current version from common.props."""
try:
@ -275,7 +291,7 @@ def main():
pr_number = f"#{pr_arg}"
version = get_version()
version = normalize_version(get_version())
if not version:
print("Could not read version from common.props.")
sys.exit(1)
@ -297,6 +313,7 @@ def main():
# Load existing document from the base branch
existing_content = get_existing_doc_from_base(base_ref)
preamble = extract_preamble(existing_content) if existing_content else ""
sections = parse_document(existing_content) if existing_content else []
# Find existing section for this version
@ -320,6 +337,8 @@ def main():
if doc_dir:
os.makedirs(doc_dir, exist_ok=True)
with open(DOC_PATH, "w") as f:
if preamble:
f.write(preamble)
f.write(HEADER + "\n")
for _, text in sections:
f.write(text.rstrip("\n") + "\n\n")

1
.gitignore

@ -270,6 +270,7 @@ modules/blogging/app/Volo.BloggingTestApp/Logs/*.*
modules/blogging/app/Volo.BloggingTestApp/wwwroot/files/*.*
modules/docs/app/VoloDocs.Web/Logs/*.*
modules/setting-management/app/Volo.Abp.SettingManagement.DemoApp/Logs/*.*
modules/openiddict/app/OpenIddict.Demo.Server/wwwroot/libs/**
templates/module/app/MyCompanyName.MyProjectName.DemoApp/Logs/*.*
templates/module/aspnet-core/host/MyCompanyName.MyProjectName.Blazor.Server.Host/Logs/logs.txt
templates/mvc/src/MyCompanyName.MyProjectName.Web/Logs/*.*

28
Directory.Packages.props

@ -117,12 +117,12 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageVersion Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.14.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
<PackageVersion Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.16.0" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.16.0" />
<PackageVersion Include="Minio" Version="6.0.5" />
<PackageVersion Include="MongoDB.Driver" Version="3.7.0" />
<PackageVersion Include="MongoDB.Driver" Version="3.7.1" />
<PackageVersion Include="NEST" Version="7.17.5" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="Nito.AsyncEx.Context" Version="5.1.2" />
@ -132,11 +132,11 @@
<PackageVersion Include="NUglify" Version="1.21.17" />
<PackageVersion Include="Nullable" Version="1.3.1" />
<PackageVersion Include="Octokit" Version="14.0.0" />
<PackageVersion Include="OpenIddict.Abstractions" Version="7.2.0" />
<PackageVersion Include="OpenIddict.Core" Version="7.2.0" />
<PackageVersion Include="OpenIddict.Server.AspNetCore" Version="7.2.0" />
<PackageVersion Include="OpenIddict.Validation.AspNetCore" Version="7.2.0" />
<PackageVersion Include="OpenIddict.Validation.ServerIntegration" Version="7.2.0" />
<PackageVersion Include="OpenIddict.Abstractions" Version="7.3.0" />
<PackageVersion Include="OpenIddict.Core" Version="7.3.0" />
<PackageVersion Include="OpenIddict.Server.AspNetCore" Version="7.3.0" />
<PackageVersion Include="OpenIddict.Validation.AspNetCore" Version="7.3.0" />
<PackageVersion Include="OpenIddict.Validation.ServerIntegration" Version="7.3.0" />
<PackageVersion Include="Oracle.EntityFrameworkCore" Version="10.23.26000" />
<PackageVersion Include="Polly" Version="8.6.3" />
<PackageVersion Include="Polly.Extensions.Http" Version="3.0.0" />
@ -183,10 +183,10 @@
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="TencentCloudSDK.Sms" Version="3.0.1273" />
<PackageVersion Include="TimeZoneConverter" Version="7.2.0" />
<PackageVersion Include="TickerQ" Version="10.1.1" />
<PackageVersion Include="TickerQ.Dashboard" Version="10.1.1" />
<PackageVersion Include="TickerQ.Utilities" Version="10.1.1" />
<PackageVersion Include="TickerQ.EntityFrameworkCore" Version="10.1.1" />
<PackageVersion Include="TickerQ" Version="10.2.0" />
<PackageVersion Include="TickerQ.Dashboard" Version="10.2.0" />
<PackageVersion Include="TickerQ.Utilities" Version="10.2.0" />
<PackageVersion Include="TickerQ.EntityFrameworkCore" Version="10.2.0" />
<PackageVersion Include="Unidecode.NET" Version="2.1.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.extensibility.execution" Version="2.9.3" />

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

@ -0,0 +1,314 @@
# Operation Rate Limiting in ABP
Almost every user-facing system eventually runs into the same problem: **some operations cannot be allowed to run without limits**.
Sometimes it's a cost issue — sending an SMS costs money, and generating a report hammers the database. Sometimes it's security — a login endpoint with no attempt limit is an open invitation for brute-force attacks. And sometimes it's a matter of fairness — your paid plan says "up to 100 data exports per month," and you need to actually enforce that.
What all these cases have in common is that the thing being limited isn't an HTTP request — it's a *business operation*, performed by a specific *who*, doing a specific *what*, against a specific *resource*.
ASP.NET Core ships with a built-in [rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) that sits in the HTTP pipeline. It's excellent for broad API protection — throttling requests per IP to fend off bots or DDoS traffic. But it only sees HTTP requests. It can tell you how many requests came from an IP address; it cannot tell you:
- **"How many verification codes has this phone number received today?"** The moment the user switches networks, the counter resets — completely useless
- **"How many reports has this user exported today?"** Switching from mobile to desktop gives them a fresh counter
- **"How many times has someone tried to log in as `alice`?"** An attacker rotating through dozens of IPs will never hit the per-IP limit
There's another gap: some rate-limiting logic has no corresponding HTTP endpoint at all — it lives inside an application service method called by multiple endpoints, or triggered by a background job. HTTP middleware has no place to hook in.
Real-world requirements tend to look like this:
- The same phone number can receive at most 3 verification codes per hour, regardless of which device or IP the request comes from
- Each user can generate at most 2 monthly sales reports per day, because a single report query scans millions of records
- Login attempts are limited to 5 failures per username per 5 minutes, *and* 20 failures per IP per hour — two independent counters, both enforced simultaneously
- Free-tier users get 50 AI calls per month, paid users get 500 — this is a product-defined quota, not a security measure
- Your system integrates with an LLM provider (OpenAI, Azure OpenAI, etc.) where every call has a real dollar cost. Without per-user or per-tenant limits, a single user can exhaust your monthly budget overnight
The pattern is clear: the identity being throttled is a **business identity** — a user, a phone number, a resource ID — not an IP address. And the action being throttled is a **business operation**, not an HTTP request.
ABP's **Operation Rate Limiting** module is built for exactly this. It lets you enforce limits directly in your application or domain layer, with full awareness of who is doing what.
This module is used by the Account (Pro) modules internally and comes pre-installed in the latest startup templates. You must have an [ABP Team or a higher license](https://abp.io/pricing) to use this module.
## Defining a Policy
The model is straightforward: define a named policy in `ConfigureServices`, then call `CheckAsync` wherever you need to enforce it.
Name your policies after the business action they protect — `"SendSmsCode"`, `"GenerateReport"`, `"CallAI"`. A clear name makes the intent obvious at the call site, and avoids the mystery of something like `"policy1"`.
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.AddPolicy("SendSmsCode", policy =>
{
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1)
.PartitionByParameter();
});
});
```
- `WithFixedWindow` sets the time window and maximum count — here, at most 1 call per minute
- `PartitionByParameter` means each distinct value you pass at call time (such as a phone number) gets its own independent counter
Then inject `IOperationRateLimitingChecker` and call `CheckAsync` at the top of the method you want to protect:
```csharp
public class SmsAppService : ApplicationService
{
private readonly IOperationRateLimitingChecker _rateLimitChecker;
public SmsAppService(IOperationRateLimitingChecker rateLimitChecker)
{
_rateLimitChecker = rateLimitChecker;
}
public virtual async Task SendCodeAsync(string phoneNumber)
{
await _rateLimitChecker.CheckAsync("SendSmsCode", phoneNumber);
// Limit not exceeded — proceed with sending the SMS
}
}
```
`CheckAsync` checks the current usage against the limit and throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is already exceeded. If the check passes, it then increments the counter and proceeds. ABP's exception pipeline catches this automatically and returns a standard error response. Put `CheckAsync` first — the rate limit check is the gate, and everything else only runs if it passes.
## Declarative Usage with `[OperationRateLimiting]`
The explicit `CheckAsync` approach is useful when you need fine-grained control — for example, when you want to check the limit conditionally, or when the parameter value comes from somewhere other than a method argument. But for the common case where you simply want to enforce a policy on every invocation of a specific method, there's a cleaner way: the `[OperationRateLimiting]` attribute.
```csharp
public class SmsAppService : ApplicationService
{
[OperationRateLimiting("SendSmsCode")]
public virtual async Task SendCodeAsync([RateLimitingParameter] string phoneNumber)
{
// Rate limit is enforced automatically — no manual CheckAsync needed.
await _smsSender.SendAsync(phoneNumber, GenerateCode());
}
}
```
The attribute works on both **Application Service methods** (via ABP's interceptor) and **MVC Controller actions** (via an action filter). No manual injection of `IOperationRateLimitingChecker` required.
### Providing the Partition Key
When using the attribute, the partition key is resolved from the method's parameters automatically:
- Mark a parameter with `[RateLimitingParameter]` to use its `ToString()` value as the key — this is the most common case when the key is a single primitive like a phone number or email.
- Have your input DTO implement `IHasOperationRateLimitingParameter` and provide a `GetPartitionParameter()` method — useful when the key is a property buried inside a complex input object.
```csharp
public class SendSmsCodeInput : IHasOperationRateLimitingParameter
{
public string PhoneNumber { get; set; }
public string Language { get; set; }
public string? GetPartitionParameter() => PhoneNumber;
}
[OperationRateLimiting("SendSmsCode")]
public virtual async Task SendCodeAsync(SendSmsCodeInput input)
{
// input.GetPartitionParameter() = input.PhoneNumber is used as the partition key.
}
```
If neither is provided, `Parameter` is `null` — which is perfectly valid for policies that use `PartitionByCurrentUser`, `PartitionByClientIp`, or similar partition types that don't rely on an explicit value.
```csharp
// Policy uses PartitionByCurrentUser — no partition key needed.
[OperationRateLimiting("GenerateReport")]
public virtual async Task<ReportDto> GenerateMonthlyReportAsync()
{
// Rate limit is checked per current user, automatically.
}
```
> The resolution order is: `[RateLimitingParameter]` first, then `IHasOperationRateLimitingParameter`, then `null`. If the method has parameters but none is resolved, a warning is logged to help you catch the misconfiguration early.
You can also place `[OperationRateLimiting]` on the class itself to apply the policy to all public methods:
```csharp
[OperationRateLimiting("MyServiceLimit")]
public class MyAppService : ApplicationService
{
public virtual async Task MethodAAsync([RateLimitingParameter] string key) { ... }
public virtual async Task MethodBAsync([RateLimitingParameter] string key) { ... }
}
```
A method-level attribute always takes precedence over the class-level one.
## Choosing a Partition Type
The partition type controls **how counters are isolated from each other** — it's the most important decision when setting up a policy, because it determines *what dimension you're counting across*.
Getting this wrong can make your rate limiting completely ineffective. Using `PartitionByClientIp` for SMS verification? An attacker just needs to switch networks. Using `PartitionByCurrentUser` for a login endpoint? There's no current user before login, so the counter has nowhere to land.
- **`PartitionByParameter`** — uses the value you explicitly pass as the partition key. This is the most flexible option. Pass a phone number, an email address, a resource ID, or any business identifier you have at hand. It's the right choice whenever you know exactly what the "who" is.
- **`PartitionByCurrentUser`** — uses the authenticated user's ID, with no value to pass. Perfect for "each user gets N per day" scenarios where user identity is all you need.
- **`PartitionByClientIp`** — uses the client's IP address. Don't rely on this alone — it's too easy to rotate. Use it as a secondary layer alongside another partition type, as in the login example below.
- **`PartitionByEmail`** and **`PartitionByPhoneNumber`** — designed for pre-authentication flows where the user isn't logged in yet. They prefer the `Parameter` value you explicitly pass, and fall back to the current user's email or phone number if none is provided.
- **`PartitionBy`** — a named custom resolver that can produce any partition key you need. Register a resolver function under a unique name via `options.AddPartitionKeyResolver("MyResolver", ctx => ...)`, then reference it by name: `.PartitionBy("MyResolver")`. You can also register and reference in one step: `.PartitionBy("MyResolver", ctx => ...)`. When the built-in options don't fit, you're free to implement whatever logic makes sense: look up a resource's owner in the database, derive a key from the user's subscription tier, partition by tenant — anything that returns a string. Because the resolver is stored by name (not as an anonymous delegate), it can be serialized and managed from a UI or database.
> The rule of thumb: partition by the identity of whoever's behavior you're trying to limit.
## Combining Rules in One Policy
A single rule covers most cases, but sometimes you need to enforce limits across multiple dimensions simultaneously. Login protection is the textbook example: throttling by username alone doesn't stop an attacker from targeting many accounts; throttling by IP alone doesn't stop an attacker with a botnet. You need both, at the same time.
```csharp
options.AddPolicy("Login", policy =>
{
// Rule 1: at most 5 attempts per username per 5-minute window
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 5)
.PartitionByParameter());
// Rule 2: at most 20 attempts per IP per hour, counted independently
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20)
.PartitionByClientIp());
});
```
The two counters are completely independent. If `alice` fails 5 times, her account is locked — but other accounts from the same IP are unaffected. If an IP accumulates 20 failures, it's blocked — but `alice` can still be targeted from other IPs until their own counters fill up.
When multiple rules are present, the module uses a two-phase approach: it checks all rules first, and only increments counters if every rule passes. This prevents a rule from consuming quota on a request that would have been rejected by another rule anyway.
## Customizing Policies from Reusable Modules
ABP modules (including your own) can ship with built-in rate limiting policies. For example, an Account module might define a `"Account.SendPasswordResetCode"` policy with conservative defaults that make sense for most applications. When you need different rules in your specific application, you have two options.
**Complete replacement with `AddPolicy`:** call `AddPolicy` with the same name and the second registration wins, replacing all rules from the module:
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.AddPolicy("Account.SendPasswordResetCode", policy =>
{
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3)
.PartitionByEmail());
});
});
```
**Partial modification with `ConfigurePolicy`:** when you only want to tweak part of a policy — change the error code, add a secondary rule, or tighten the window — use `ConfigurePolicy`. The builder starts pre-populated with the module's existing rules, so you only express what changes.
For example, keep the module's default rules but assign your own localized error code:
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.WithErrorCode("MyApp:PasswordResetLimit");
});
});
```
Or add a secondary IP-based rule on top of what the module already defined, without touching it:
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20)
.PartitionByClientIp());
});
});
```
If you want a clean slate, call `ClearRules()` first and then define entirely new rules — this gives you the same result as `AddPolicy` but makes the intent explicit:
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.ClearRules()
.WithFixedWindow(TimeSpan.FromMinutes(10), maxCount: 5)
.PartitionByEmail();
});
});
```
`ConfigurePolicy` throws if the policy name doesn't exist — which catches typos at startup rather than silently doing nothing.
The general rule: use `AddPolicy` for full replacements, `ConfigurePolicy` for surgical modifications.
## Beyond Just Checking
Not every scenario calls for throwing an exception. `IOperationRateLimitingChecker` provides three additional methods for more nuanced control.
**`IsAllowedAsync`** performs a read-only check — it returns `true` or `false` without touching any counter. The most common use case is UI pre-checking: when a user opens the "send verification code" page, check the limit first. If they've already hit it, disable the button and show a countdown immediately, rather than making them click and get an error. That's a meaningfully better experience.
```csharp
var isAllowed = await _rateLimitChecker.IsAllowedAsync("SendSmsCode", phoneNumber);
```
**`GetStatusAsync`** also reads without incrementing, but returns richer data: `RemainingCount`, `RetryAfter`, and `CurrentCount`. This is what you need to build quota displays — "You have 2 exports remaining today" or "Please try again in 47 seconds" — which are far friendlier than a raw 429.
```csharp
var status = await _rateLimitChecker.GetStatusAsync("SendSmsCode", phoneNumber);
// status.RemainingCount, status.RetryAfter, status.IsAllowed ...
```
**`ResetAsync`** clears the counter for a given policy and context. Useful in admin panels where support staff can manually unblock a user, or in test environments where you need to reset state between runs.
```csharp
await _rateLimitChecker.ResetAsync("SendSmsCode", phoneNumber);
```
## When the Limit Is Hit
When `CheckAsync` triggers, it throws `AbpOperationRateLimitingException`, which:
- Inherits from `BusinessException` and maps to HTTP **429 Too Many Requests**
- Is handled automatically by ABP's exception pipeline
- Carries useful metadata: `RetryAfterSeconds`, `RemainingCount`, `MaxCount`, `CurrentCount`
By default, the error code sent to the client is a generic one from the module. If you want each operation to produce its own localized message — "Too many verification code requests, please wait before trying again" instead of a generic error — assign a custom error code to the policy:
```csharp
options.AddPolicy("SendSmsCode", policy =>
{
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1)
.PartitionByParameter()
.WithErrorCode("App:SmsCodeLimit");
});
```
> For details on mapping error codes to localized messages, see [Exception Handling](https://abp.io/docs/latest/framework/fundamentals/exception-handling) in the ABP docs.
## Turning It Off in Development
Rate limiting and local development don't mix well. When you're iterating quickly and calling the same endpoint a dozen times to test something, getting blocked by a 429 every few seconds is genuinely painful. Disable the module in your development environment:
```csharp
public override void ConfigureServices(ServiceConfigurationContext context)
{
var hostEnvironment = context.Services.GetHostingEnvironment();
Configure<AbpOperationRateLimitingOptions>(options =>
{
if (hostEnvironment.IsDevelopment())
{
options.IsEnabled = false;
}
});
}
```
## Summary
ABP's Operation Rate Limiting fills the gap that ASP.NET Core's HTTP middleware can't: rate limiting with real awareness of *who* is doing *what*. Define a named policy, pick a time window, a max count, and a partition type. Then either call `CheckAsync` explicitly, or just add `[OperationRateLimiting]` to your method and let the framework handle the rest. Counter storage, distributed locking, and exception handling are all taken care of.
## References
- [Operation Rate Limiting (Pro)](https://abp.io/docs/latest/modules/operation-rate-limiting)
- [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit)
- [Exception Handling](https://abp.io/docs/latest/framework/fundamentals/exception-handling)

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

113
docs/en/Community-Articles/2026-03-10-Tutorial-Validator/article.md

@ -0,0 +1,113 @@
# Automatically Validate Your Documentation: How We Built a Tutorial Validator
Writing a tutorial is difficult. Keeping technical documentation accurate over time is even harder.
If you maintain developer documentation, you probably know the problem: a tutorial that worked a few months ago can silently break after a framework update, dependency change, or a small missing line in a code snippet.
New developers follow the guide, encounter an error, and quickly lose trust in the documentation.
To solve this problem, we built the tutorial validator — an open-source AI-powered tutorial validator that automatically verifies whether a software tutorial actually works from start to finish.
Instead of manually reviewing documentation, the tutorial validator behaves like a real developer following your guide step by step.
It reads instructions, runs commands, writes files, executes the application, and verifies expected results.
We initially created it to automatically validate ABP Framework tutorials, then released it as an open-source tool so anyone can use it to test their own documentation.
![the tutorial validator Orchestrator](docs/images/image.png)
## The Problem: Broken Tutorials in Technical Documentation
Many documentation issues are difficult to catch during normal reviews.
Common problems include:
- A command assumes a file already exists
- A code snippet misses a namespace or import
- A tutorial step relies on hidden context
- An endpoint is expected to respond but fails
- A dependency version changed and breaks the project
Traditional proofreading tools only check grammar or wording.
**The tutorial validator focuses on execution correctness.**
It treats tutorials like testable workflows, ensuring that every step works exactly as written.
## How the Tutorial Validator Works?
the tutorial validator validates tutorials using a three-stage pipeline:
1. **Analyst**: Scrapes tutorial pages and converts instructions into a structured test plan
2. **Executor**: Follows the plan step by step in a clean environment
3. **Reporter**: Produces a clear result summary and optional notifications
![the tutorial validator Analyst](docs/images/image-1.png)
It identifies commands, code edits, HTTP requests, and expected outcomes.
The key idea is simple: if a developer would need to do it, the validator does it too.
That includes running terminal commands, editing files, checking HTTP responses, and validating build outcomes.
![the tutorial validator Executor](docs/images/image-2.png)
## Why Automated Tutorial Validation Matters?
The tutorial validator is designed for practical documentation quality, not just technical experimentation.
- **Catches real-world breakages early** before readers report them
- **Creates repeatable validation** instead of one-off manual checks
- **Works well in teams** through report outputs, logs, and CI-friendly behavior
- **Supports different strictness levels** with developer personas (`junior`, `mid`, `senior`)
For example, `junior` and `mid` personas are great for spotting unclear documentation, while `senior` helps identify issues an experienced developer could work around.
## Built for ABP, Open for Everyone
Although TutorialValidator was originally built to validate **ABP Framework tutorials**, it works with **any publicly accessible software tutorial**.
It supports validating any publicly accessible software tutorial and can run in:
- **Docker mode** for clean, isolated execution (recommended)
- **Local mode** for faster feedback when your environment is already prepared
It also supports multiple AI providers, including OpenAI, Azure OpenAI, and OpenAI-compatible endpoints.
## Open Source and Easily Extensible
The tutorial validator is designed with a modular architecture.
The project consists of multiple focused components:
- **Core** – shared models and contracts
- **Analyst** – tutorial scraping and step extraction
- **Executor** – step-by-step execution engine
- **Orchestrator** – workflow coordination
- **Reporter** – notifications and result summaries
This architecture makes it easy to extend the validator with:
- new step types
- additional AI providers
- custom reporting integrations
This architecture keeps the project easy to understand and extend. Teams can add new step types, plugins, or reporting channels based on their own workflow.
## Final Thoughts
Documentation is a critical part of the product experience.
When tutorials break, developer trust breaks too.
TutorialValidator helps teams move from:
> We believe this tutorial works 🙄
to
> We verified this tutorial works ✅
If your team maintains **technical tutorials, developer guides, or framework documentation**, automated tutorial validation can provide a powerful safety net.
Documentation is part of the product experience. When tutorials fail, trust fails.
If your team maintains technical tutorials, this project can give you a practical safety net and a repeatable quality process.
---
You can find the source-code of the tutorial validator at this repo 👉 https://github.com/abpframework/tutorial-validator
We would love to hear your feedback, ideas and waiting PRs to improve this application.

BIN
docs/en/Community-Articles/2026-03-10-Tutorial-Validator/docs/images/image-1.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

BIN
docs/en/Community-Articles/2026-03-10-Tutorial-Validator/docs/images/image-2.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
docs/en/Community-Articles/2026-03-10-Tutorial-Validator/docs/images/image.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

185
docs/en/Community-Articles/2026-03-12-OpenIddict-private-key-jwt/POST.md

@ -0,0 +1,185 @@
# Secure Client Authentication with private_key_jwt in ABP 10.3
If you've built a confidential client with ABP's OpenIddict module, you know the drill: create an application in the management UI, set a `client_id`, generate a `client_secret`, and paste that secret into your client's `appsettings.json` or environment variables. It works. It's familiar. And for a lot of projects, it's perfectly fine.
But `client_secret` is a **shared secret** — and shared secrets carry an uncomfortable truth: the same value exists in two places at once. The authorization server stores a hash of it in the database, and your client stores the raw value in configuration. That means two potential leak points. Worse, the secret has no inherent identity. Anyone who obtains the string can impersonate your client and the server has no way to tell the difference.
For many teams, this tradeoff is acceptable. But certain scenarios make it hard to ignore:
- **Microservice-to-microservice calls**: A backend mesh of a dozen services, each with its own `client_secret` scattered across deployment configs and CI/CD pipelines. Rotating them across environments without missing one becomes a coordination problem.
- **Multi-tenant SaaS platforms**: Every tenant's client application deserves truly isolated credentials. With shared secrets, the database holds hashed copies for all tenants — a breach of that table is a breach of everyone's credentials.
- **Financial-grade API (FAPI) compliance**: Standards like [FAPI 2.0](https://openid.net/specs/fapi-2_0-security-profile.html) explicitly require asymmetric client authentication. `client_secret` doesn't make the cut.
- **Zero-trust architectures**: In a zero-trust model, identity must be cryptographically provable, not based on a string that can be copied and pasted.
The underlying problem is that a shared secret is just a password. It can be stolen, replicated, and used without leaving a trace. The fix has existed in cryptography for decades: **asymmetric keys**.
With asymmetric key authentication, the client generates a key pair. The public key is registered with the authorization server. The private key never leaves the client. Each time the client needs a token, it signs a short-lived JWT — called a _client assertion_ — with the private key. The server verifies the signature using the registered public key. There is no secret on the server side that could be used to forge a request, because the private key is never transmitted or stored remotely.
This is exactly what the **`private_key_jwt`** client authentication method, defined in [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication), provides. ABP's OpenIddict module now supports it end-to-end: you register a **JSON Web Key Set (JWKS)** containing your public key through the application management UI (ABP Commercial), and your client authenticates using the corresponding private key. The key generation tooling (`abp generate-jwks`) ships as part of the open-source ABP CLI.
> This feature is available starting from **ABP Framework 10.3**.
## How It Works
The flow is straightforward:
1. The client holds an RSA key pair — **private key** (kept locally) and **public key** (registered on the authorization server as a JWKS).
2. On each token request, the client uses the private key to sign a JWT with a short expiry and a unique `jti` claim.
3. The authorization server verifies the signature against the registered public key and issues a token if it checks out.
The private key never leaves the client. Even if someone obtains the authorization server's database, there's nothing there that can be used to generate a valid client assertion.
## Generating a Key Pair
ABP CLI includes a `generate-jwks` command that creates an RSA key pair in the right formats:
```bash
abp generate-jwks
```
This produces two files in the current directory:
- `jwks.json` — the public key in JWKS format, to be uploaded to the server
- `jwks-private.pem` — the private key in PKCS#8 PEM format, to be kept on the client
You can customize the output directory, key size, and signing algorithm:
```bash
abp generate-jwks --alg RS512 --key-size 4096 -o ./keys -f myapp
```
> Supported algorithms: `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`. The default is `RS256` with a 2048-bit key.
The command also prints the contents of `jwks.json` to the console so you can copy it directly.
## Registering the JWKS in the Management UI
Open **OpenIddict → Applications** in the ABP admin panel and create or edit a confidential application (Client Type: `Confidential`).
In the **Client authentication method** section, you'll find the new **JSON Web Key Set** field.
![](./create-edit-ui.png)
Paste the contents of `jwks.json` into the **JSON Web Key Set** field:
```json
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "6444...",
"alg": "RS256",
"n": "tx...",
"e": "AQAB"
}
]
}
```
Save the application. It's now configured for `private_key_jwt` authentication. You can set either `client_secret` or a JWKS, or both — ABP enforces that a confidential application always has at least one credential.
## Requesting a Token with the Private Key
On the client side, each token request requires building a _client assertion_ JWT signed with the private key. Here's a complete `client_credentials` example:
```csharp
// Discover the authorization server endpoints (including the issuer URI).
var client = new HttpClient();
var configuration = await client.GetDiscoveryDocumentAsync("https://your-auth-server/");
// Load the private key generated by `abp generate-jwks`.
using var rsaKey = RSA.Create();
rsaKey.ImportFromPem(await File.ReadAllTextAsync("jwks-private.pem"));
// Read the kid from jwks.json so it stays in sync with the server-registered public key.
string? signingKid = null;
if (File.Exists("jwks.json"))
{
using var jwksDoc = JsonDocument.Parse(await File.ReadAllTextAsync("jwks.json"));
if (jwksDoc.RootElement.TryGetProperty("keys", out var keysElem) &&
keysElem.GetArrayLength() > 0 &&
keysElem[0].TryGetProperty("kid", out var kidElem))
{
signingKid = kidElem.GetString();
}
}
var signingKey = new RsaSecurityKey(rsaKey) { KeyId = signingKid };
var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256);
// Build the client assertion JWT.
var now = DateTime.UtcNow;
var jwtHandler = new JsonWebTokenHandler();
var clientAssertionToken = jwtHandler.CreateToken(new SecurityTokenDescriptor
{
// OpenIddict requires typ = "client-authentication+jwt" for client assertion JWTs.
TokenType = "client-authentication+jwt",
Issuer = "MyClientId",
// aud must equal the authorization server's issuer URI from the discovery document,
// not the token endpoint URL.
Audience = configuration.Issuer,
Subject = new ClaimsIdentity(new[]
{
new Claim(JwtRegisteredClaimNames.Sub, "MyClientId"),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}),
IssuedAt = now,
NotBefore = now,
Expires = now.AddMinutes(5),
SigningCredentials = signingCredentials,
});
// Request a token using the client_credentials flow.
var tokenResponse = await client.RequestClientCredentialsTokenAsync(
new ClientCredentialsTokenRequest
{
Address = configuration.TokenEndpoint,
ClientId = "MyClientId",
ClientCredentialStyle = ClientCredentialStyle.PostBody,
ClientAssertion = new ClientAssertion
{
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
Value = clientAssertionToken,
},
Scope = "MyAPI",
});
```
A few things worth paying attention to:
- **`TokenType`** must be `"client-authentication+jwt"`. OpenIddict rejects client assertion JWTs that don't carry this header.
- **`Audience`** must match the authorization server's issuer URI exactly — use `configuration.Issuer` from the discovery document, not the token endpoint URL.
- **`Jti`** must be unique per request to prevent replay attacks.
- Keep **`Expires`** short (five minutes or less). A client assertion is a one-time proof of identity, not a long-lived credential.
This example uses [IdentityModel](https://github.com/IdentityModel/IdentityModel) for the token request helpers and [Microsoft.IdentityModel.JsonWebTokens](https://www.nuget.org/packages/Microsoft.IdentityModel.JsonWebTokens) for JWT creation.
## Key Rotation Without Downtime
One of the practical advantages of JWKS is that it can hold multiple public keys simultaneously. This makes **zero-downtime key rotation** straightforward:
1. Run `abp generate-jwks` to produce a new key pair.
2. Append the new public key to the `keys` array in your existing `jwks.json` and update the JWKS in the management UI.
3. Switch the client to sign assertions with the new private key.
4. Once the transition is complete, remove the old public key from the JWKS.
During the transition window, both the old and new public keys are registered on the server, so any in-flight requests signed with either key will still validate correctly.
## Summary
To use `private_key_jwt` authentication in an ABP Pro application:
1. Run `abp generate-jwks` to generate an RSA key pair.
2. Paste the `jwks.json` contents into the **JSON Web Key Set** field in the OpenIddict application management UI.
3. On the client side, sign a short-lived _client assertion_ JWT with the private key — making sure to set the correct `typ`, `aud` (from the discovery document), and a unique `jti` — then use it to request a token.
ABP handles public key storage and validation automatically. OpenIddict handles the signature verification on the token endpoint. As a developer, you only need to keep the private key file secure — there's no shared secret to synchronize between client and server.
## References
- [OpenID Connect Core — Client Authentication](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication)
- [RFC 7523 — JWT Profile for Client Authentication](https://datatracker.ietf.org/doc/html/rfc7523)
- [ABP OpenIddict Module Documentation](https://abp.io/docs/latest/modules/openiddict)
- [ABP CLI Documentation](https://abp.io/docs/latest/cli)
- [OpenIddict Documentation](https://documentation.openiddict.com/)

BIN
docs/en/Community-Articles/2026-03-12-OpenIddict-private-key-jwt/cover.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
docs/en/Community-Articles/2026-03-12-OpenIddict-private-key-jwt/create-edit-ui.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

151
docs/en/Community-Articles/2026-03-17-OpenAI-Compatible-Endpoints/POST.md

@ -0,0 +1,151 @@
# One Endpoint, Many AI Clients: Turning ABP Workspaces into OpenAI-Compatible Models
ABP's AI Management module already makes it easy to define and manage AI workspaces (provider, model, API key/base URL, system prompt, permissions, MCP tools, RAG settings, and more). With **ABP v10.2**, there is a major addition: you can now expose those workspaces through **OpenAI-compatible endpoints** under `/v1`.
That changes the integration story in a practical way. Instead of wiring every external tool directly to a provider, you can point those tools to ABP and keep runtime decisions centralized in one place.
In this post, we will walk through a practical setup with **AnythingLLM** and show why this pattern is useful in real projects.
Before we get into the details, here's a quick look at the full flow in action:
## See It in Action: AnythingLLM + ABP
The demo below shows the full flow: connecting an OpenAI-compatible client to ABP, selecting a workspace-backed model, and sending a successful chat request through `/v1`.
![ABP AI Management OpenAI-compatible endpoints demo](./openai-compatible-endpoints-demo.gif)
## Why This Is a Big Deal
Many teams end up with AI configuration spread across multiple clients and services. Updating providers, rotating keys, or changing model behavior can become operationally messy.
With ABP in front of your AI traffic:
- Clients keep speaking the familiar OpenAI contract.
- ABP resolves the requested `model` to a workspace.
- The workspace decides which provider/model settings are actually used.
This gives you a clean split: standardized client integration outside, governed AI configuration inside.
## Key Concept: Workspace = Model
OpenAI-compatible clients send a `model` value.
In ABP AI Management, that `model` maps to a **workspace name**.
**For example:**
- Workspace name: `SupportAgent`
- Client request model: `SupportAgent`
When the client calls `/v1/chat/completions` with `"model": "SupportAgent"`, ABP routes the request to that workspace and applies that workspace's provider (OpenAI, Ollama etc.) and model configuration.
This is the main mental model to keep in mind while integrating any OpenAI-compatible tool with ABP.
## Endpoints Exposed by ABP v10.2
The AI Management module exposes OpenAI-compatible REST endpoints at `/v1`.
| Endpoint | Method | Description |
| ---------------------------- | ------ | ---------------------------------------------- |
| `/v1/chat/completions` | POST | Chat completions (streaming and non-streaming) |
| `/v1/completions` | POST | Legacy text completions |
| `/v1/models` | GET | List available models (workspaces) |
| `/v1/models/{modelId}` | GET | Get a single model (workspace) |
| `/v1/embeddings` | POST | Generate embeddings |
| `/v1/files` | GET | List files |
| `/v1/files` | POST | Upload a file |
| `/v1/files/{fileId}` | GET | Get file metadata |
| `/v1/files/{fileId}` | DELETE | Delete a file |
| `/v1/files/{fileId}/content` | GET | Download file content |
All endpoints require `Authorization: Bearer <token>`.
## Quick Setup with AnythingLLM
Before configuration, ensure:
1. AI Management is installed and running in your ABP app.
2. At least one workspace is created and **active**.
3. You have a valid Bearer token for your ABP application.
### 1) Get an access token
Use any valid token accepted by your app. In a demo-style setup, token retrieval can look like this:
```bash
curl -X POST http://localhost:44337/connect/token \
-d "grant_type=password&username=admin&password=1q2w3E*&client_id=DemoApp_API&client_secret=1q2w3e*&scope=DemoApp"
```
Use the returned `access_token` as the API key value in your OpenAI-compatible client.
### 2) Configure AnythingLLM as Generic OpenAI
In **AnythingLLM -> Settings -> LLM Preference**, select **Generic OpenAI** and set:
| Setting | Value |
| -------------------- | --------------------------- |
| Base URL | `http://localhost:44337/v1` |
| API Key | `<access_token>` |
| Chat Model Selection | Select an active workspace |
In most OpenAI-compatible UIs, the app adds `Bearer` automatically, so the API key field should contain only the raw token string.
### 3) Optional: configure embeddings
If you want RAG flows through ABP, go to **Settings -> Embedding Preference** and use the same Base URL/API key values.
Then select a workspace that has embedder settings configured.
## Validate the Flow
### List models (workspaces)
```bash
curl http://localhost:44337/v1/models \
-H "Authorization: Bearer <your-token>"
```
### Chat completion
```bash
curl -X POST http://localhost:44337/v1/chat/completions \
-H "Authorization: Bearer <your-token>" \
-H "Content-Type: application/json" \
-d '{
"model": "MyWorkspace",
"messages": [
{ "role": "user", "content": "Hello from ABP OpenAI-compatible endpoint!" }
]
}'
```
### Optional SDK check (Python)
```python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:44337/v1",
api_key="<your-token>"
)
response = client.chat.completions.create(
model="MyWorkspace",
messages=[{"role": "user", "content": "Hello!"}]
)
print(response.choices[0].message.content)
```
## Where This Fits in Real Projects
This approach is a strong fit when you want to:
- Keep ABP as the central control plane for AI workspaces.
- Let client tools integrate through a standard OpenAI contract.
- Switch providers or model settings without rewriting client-side integration.
If your team uses multiple AI clients, this pattern keeps integration simple while preserving control where it matters.
## Learn More
- [ABP AI Management Documentation](https://abp.io/docs/10.2/modules/ai-management)

BIN
docs/en/Community-Articles/2026-03-17-OpenAI-Compatible-Endpoints/cover-image.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

BIN
docs/en/Community-Articles/2026-03-17-OpenAI-Compatible-Endpoints/openai-compatible-endpoints-demo.gif

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 MiB

167
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/POST.md

@ -0,0 +1,167 @@
# Shared User Accounts in ABP Multi-Tenancy
Multi-tenancy is built on **isolation** — isolated data, isolated permissions, isolated users. ABP's default behavior has always followed this assumption: one user belongs to exactly one tenant. Clean, simple, no ambiguity. For most SaaS applications, that's exactly what you want. (The new `TenantUserSharingStrategy` enum formally names this default behavior `Isolated`.)
But isolation is **the system's** concern, not **the user's**. In practice, people's work doesn't always line up neatly with tenant boundaries.
Think about a financial consultant who works with three different companies — each one a tenant in your system. Under the Isolated model, she needs three separate accounts, three passwords. Forgot which password goes with which company? Good luck. Worse, the system sees three unrelated people — there's nothing linking those accounts to the same human being.
This comes up more often than you'd think:
- In a **corporate group**, an IT admin manages multiple subsidiaries, each running as its own tenant. Every day means logging out, logging back in with different credentials, over and over
- A **SaaS platform's ops team** needs to hop into different customer tenants to debug issues. Each time they create a throwaway account, then delete it — or just share one account and lose all audit trail
- Some users resort to email aliases (`alice+company1@example.com`) to work around uniqueness constraints — that's not a solution, that's a hack
The common thread here: the user's **identity** is global, but their **working context** is per-tenant. The problem isn't a technical limitation — it's that the Isolated assumption ("one user, one tenant") simply doesn't hold in these scenarios.
What's needed is not "one account per tenant" but "one account, multiple tenants."
ABP's **Shared User Accounts** (`TenantUserSharingStrategy.Shared`) does exactly this. It makes user identity global and turns tenants into workspaces that a user can join and switch between — similar to how one person can belong to multiple workspaces in Slack.
> This is a **commercial** feature, available starting from **ABP 10.2**, provided by the Account.Pro and Identity.Pro modules.
## Enabling the Shared Strategy
A single configuration is all it takes:
```csharp
Configure<AbpMultiTenancyOptions>(options =>
{
options.IsEnabled = true;
options.UserSharingStrategy = TenantUserSharingStrategy.Shared;
});
```
The most important behavior change after switching to Shared: **username and email uniqueness become global** instead of per-tenant. This follows naturally — if the same account needs to be recognized across tenants, its identifiers must be unique across the entire system.
Security-related settings (2FA, account lockout, password policies, captcha, etc.) are also managed at the **Host** level. This makes sense too: if user identity is global, the security rules around it should be global as well.
## One Account, Multiple Tenants
With the Shared strategy enabled, the day-to-day user experience changes fundamentally.
When a user is associated with only one tenant, the system recognizes it automatically and signs them in directly — the user doesn't even notice that tenants exist. When the user belongs to multiple tenants, the login flow presents a tenant selection screen after credentials are verified:
![tenant-selection](./tenant-selection.png)
After signing into a tenant, a tenant switcher appears in the user menu — click it anytime to jump to another tenant without signing out. ABP re-issues the authentication ticket (with the new `TenantId` in the claims) on each switch, so the permission system is fully independent per tenant.
![switch-tenant](./switch-tenant.png)
Users can also leave a tenant. Leaving doesn't delete the association record — it marks it as inactive. This preserves foreign key relationships with other entities. If the user is invited back later, the association is simply reactivated instead of recreated.
Back to our earlier scenario: the financial consultant now has one account, one password. She picks which company to work in at login, switches between them during the day. The system knows it's the same person, and the audit log can trace her actions across every tenant.
## Invitations
Users don't just appear in a tenant — someone has to invite them. This is the core operation from the administrator's perspective.
A tenant admin opens the invitation dialog, enters one or more email addresses (batch invitations are supported), and can pre-assign roles — so the user gets the right permissions the moment they join, no extra setup needed:
![invite-user](./invite-user.png)
The invited person receives an email with a link. What happens next depends on whether they already have an account.
If they **already have an account**, they see a confirmation page and can join the tenant with a single click:
![exist-user-accept](./exist-user-accept.png)
If they **don't have an account yet**, the link takes them to a registration form. Once they register, they're automatically added to the tenant:
![new-user-accept](./new-user-accept.png)
Admins can also manage pending invitations at any time — resend emails or revoke invitations.
> The invitation feature is also available under the Isolated strategy, but invited users can only join a single tenant.
## Setting Up a New Tenant
There's a notable shift in how new tenants are bootstrapped.
Under the Isolated model, creating a tenant typically seeds an `admin` user automatically. With Shared, this no longer happens — because users are global, and it doesn't make sense to create one out of thin air for a specific tenant.
Instead, you create the tenant first, then invite someone in and grant them the admin role.
![invite-admin-user-to-join-tenant](./invite-admin-user-to-join-tenant.png)
![invite-admin-user-to-join-tenant-modal](./invite-admin-user-to-join-tenant-modal.png)
This is a natural fit — the admin is just a global user who happens to hold the admin role in this particular tenant.
## Where Do Newly Registered Users Go?
Under the Shared strategy, self-registration runs into an interesting problem: the system doesn't know which tenant the user wants to join. Without being signed in, tenant context is usually determined by subdomain or a tenant switcher on the login page — but for a brand-new user, those signals might not exist at all.
So ABP's approach is: **don't establish any tenant association at registration time**. A newly registered user doesn't belong to any tenant, and doesn't belong to the Host either — this is an entirely new state. ABP still lets these users sign in, change their password, and manage their account, but they can't access any permission-protected features within a tenant.
`AbpIdentityPendingTenantUserOptions.Strategy` controls what happens in this "pending" state.
**CreateTenant** — automatically creates a tenant for the new user. This fits the "sign up and get your own workspace" pattern, like how Slack or Notion handles registration: you register, the system spins up a workspace for you.
```csharp
Configure<AbpIdentityPendingTenantUserOptions>(options =>
{
options.Strategy = AbpIdentityPendingTenantUserStrategy.CreateTenant;
});
```
![new-user-join-strategy-create-tenant](./new-user-join-strategy-create-tenant.png)
**Inform** (the default) — shows a message telling the user to contact an administrator to join a tenant. This is the right choice for invite-only platforms where users must be brought in by an existing tenant admin.
```csharp
Configure<AbpIdentityPendingTenantUserOptions>(options =>
{
options.Strategy = AbpIdentityPendingTenantUserStrategy.Inform;
});
```
![new-user-join-strategy-inform](./new-user-join-strategy-inform.png)
There's also a **Redirect** strategy that sends the user to a custom URL for more complex flows.
> See the [official documentation](https://abp.io/docs/latest/modules/account/shared-user-accounts) for full configuration details.
## Database Considerations
The Shared strategy introduces some mechanisms and constraints at the database level that are worth understanding.
### Global Uniqueness: Enforced in Code, Not by Database Indexes
Username and email uniqueness checks must span all tenants. ABP disables the tenant filter (`TenantFilter.Disable()`) during validation and searches globally for conflicts.
A notable design choice here: **global uniqueness is enforced at the application level, not through database unique indexes**. The reason is practical — in a database-per-tenant setup, users live in separate physical databases, so a cross-database unique index simply isn't possible. Even in a shared database, soft-delete complicates unique indexes (you'd need a composite index on "username + deletion time"). So ABP handles this in application code instead.
To keep things safe under concurrency — say two tenant admins invite the same email address at the same time — ABP uses a **distributed lock** to serialize uniqueness validation. This means your production environment needs a distributed lock provider configured (such as Redis).
The uniqueness check goes beyond just "no duplicate usernames." ABP also checks for **cross-field conflicts**: a user's username can't match another user's email, and vice versa. This prevents identity confusion in edge cases.
### Tenants with Separate Databases
If some of your tenants use their own database (database-per-tenant), the Shared strategy requires extra attention.
The login flow and tenant selection happen on the **Host side**. This means the Host database's `AbpUsers` table must contain records for all users — even those originally created in a tenant's separate database. ABP's approach is replication: it saves the primary user record in the Host context and creates a copy in the tenant context. In a shared-database setup, both records live in the same table; in a database-per-tenant setup, they live in different physical databases. Updates and deletes are kept in sync automatically.
If your application uses social login or passkeys, the `AbpUserLogins` and `AbpUserPasskeys` tables also need to be synced in the Host database.
### Migrating from the Isolated Strategy
If you're moving an existing multi-tenant application from Isolated to Shared, ABP automatically runs a global uniqueness check when you switch the strategy and reports any conflicts.
The most common conflict: the same email address registered as separate users in different tenants. You'll need to resolve these first — merge the accounts or change one side's email — before the Shared strategy can be enabled.
## Summary
ABP's Shared User Accounts addresses a real-world need in multi-tenant systems: one person working across multiple tenants.
- One configuration switch to `TenantUserSharingStrategy.Shared`
- User experience: pick a tenant at login, switch between tenants anytime, one password for everything
- Admin experience: invite users by email, pre-assign roles on invitation
- Database notes: configure a distributed lock provider for production; tenants with separate databases need user records replicated in the Host database
ABP takes care of global uniqueness validation, tenant association management, and login flow adaptation under the hood.
## References
- [Shared User Accounts](https://abp.io/docs/latest/modules/account/shared-user-accounts)
- [ABP Multi-Tenancy](https://abp.io/docs/latest/framework/architecture/multi-tenancy)

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/cover.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/exist-user-accept.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/invite-admin-user-to-join-tenant-modal.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/invite-admin-user-to-join-tenant.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/invite-user.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/new-user-accept.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/new-user-join-strategy-create-tenant.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/new-user-join-strategy-inform.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/switch-tenant.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/tenant-selection.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

94
docs/en/cli/index.md

@ -75,6 +75,7 @@ Here is the list of all available commands before explaining their details:
* **[`install-old-cli`](../cli#install-old-cli)**: Installs old ABP CLI.
* **[`mcp-studio`](../cli#mcp-studio)**: Starts ABP Studio MCP bridge for AI tools (requires ABP Studio running).
* **[`generate-razor-page`](../cli#generate-razor-page)**: Generates a page class that you can use it in the ASP NET Core pipeline to return an HTML page.
* **[`generate-jwks`](../cli#generate-jwks)**: Generates an RSA key pair (JWKS public key + PEM private key) for OpenIddict `private_key_jwt` client authentication.
### help
@ -1127,6 +1128,99 @@ app.Use(async (httpContext, next) =>
* ```--version``` or ```-v```: Specifies the version for ABP CLI to be installed.
### generate-jwks
Generates an RSA key pair for use with OpenIddict `private_key_jwt` client authentication.
The command produces two files:
| File | Description |
|---|---|
| `<prefix>.json` | JWKS (JSON Web Key Set) containing the **public key**. Paste this into the **JSON Web Key Set** field of your OpenIddict application in the ABP management UI. |
| `<prefix>-private.pem` | PKCS#8 PEM **private key**. Store this securely in your client application and use it to sign JWT client assertions. |
> **Security notice:** Never commit the private key file to source control. Add it to `.gitignore`. Only the JWKS (public key) needs to be shared with the authorization server.
Usage:
```bash
abp generate-jwks [options]
```
#### Options
* `--output` or `-o`: Output directory. Defaults to the current directory.
* `--key-size` or `-s`: RSA key size in bits. Supported values: `2048` (default), `4096`.
* `--alg`: Signing algorithm. Supported values: `RS256` (default), `RS384`, `RS512`, `PS256`, `PS384`, `PS512`.
* `--kid`: Custom Key ID. Auto-generated if not specified.
* `--file` or `-f`: Output file name prefix. Defaults to `jwks`. Generates `<prefix>.json` and `<prefix>-private.pem`.
#### Examples
```bash
# Generate with defaults (2048-bit RS256, current directory)
abp generate-jwks
# Generate with RS512 and 4096-bit key
abp generate-jwks --alg RS512 --key-size 4096
# Output to a specific directory with a custom file prefix
abp generate-jwks -o ./keys -f myapp
```
#### Workflow
1. Run `abp generate-jwks` to generate the key pair.
2. Open the ABP OpenIddict application management UI, select your **Confidential** application, choose **JWKS (private_key_jwt)** as the authentication method, and paste the contents of `jwks.json` into the **JSON Web Key Set** field.
3. In your client application, load the private key from the PEM file and sign JWT client assertions:
```csharp
// Load private key from PEM file
using var rsa = RSA.Create();
rsa.ImportFromPem(await File.ReadAllTextAsync("jwks-private.pem"));
// The kid must match the "kid" field in the JWKS registered on the server
var signingKey = new RsaSecurityKey(rsa) { KeyId = "<kid-from-jwks.json>" };
var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256);
var now = DateTime.UtcNow;
var jwtHandler = new JsonWebTokenHandler();
var clientAssertion = jwtHandler.CreateToken(new SecurityTokenDescriptor
{
// OpenIddict requires typ = "client-authentication+jwt"
TokenType = "client-authentication+jwt",
// iss and sub must both equal the client_id
Issuer = "<your-client-id>",
Audience = "<authorization-server-issuer-uri>",
Subject = new ClaimsIdentity(new[]
{
new Claim(JwtRegisteredClaimNames.Sub, "<your-client-id>"),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}),
IssuedAt = now,
NotBefore = now,
Expires = now.AddMinutes(5),
SigningCredentials = signingCredentials,
});
// Use the assertion in the token request
var tokenResponse = await httpClient.RequestClientCredentialsTokenAsync(
new ClientCredentialsTokenRequest
{
Address = "<token-endpoint>",
ClientId = "<your-client-id>",
ClientCredentialStyle = ClientCredentialStyle.PostBody,
ClientAssertion = new ClientAssertion
{
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
Value = clientAssertion,
},
Scope = "<requested-scopes>",
});
```
## See Also
* [Examples for the new command](./new-command-samples.md)

168
docs/en/contribution/angular-ui.md

@ -7,58 +7,168 @@
# Contribution Guide for the Angular UI
This guide explains how to set up the ABP Angular UI workspace, run the demo app, and prepare your environment to contribute UI changes. It assumes that you are already familiar with basic Angular and .NET development.
> Before sending a pull request for Angular UI changes, please also read the main [Contribution Guide](index.md).
## Pre-requirements
- Dotnet core SDK https://dotnet.microsoft.com/en-us/download
- Nodejs LTS https://nodejs.org/en/
- Docker https://docs.docker.com/engine/install
- Angular CLI. https://angular.dev/tools/cli
- Abp CLI https://docs.abp.io/en/abp/latest/cli
- A code editor
Make sure you have the following tools installed:
- [.NET SDK](https://dotnet.microsoft.com/en-us/download)
- [Node.js LTS](https://nodejs.org/en/) (recommended: use the version supported by the Angular CLI used in this repository)
- [Docker Engine](https://docs.docker.com/engine/install/) (required if you use the sample SQL Server and Redis containers)
- [Angular CLI](https://angular.dev/tools/cli)
- [ABP CLI](https://docs.abp.io/en/abp/latest/cli)
- A code editor (for example, Visual Studio Code or Visual Studio)
Note: This article prepare Windows OS. You may change the path type of your OS.
> This article uses Windows-style paths in examples. On Unix-like systems, replace backslashes (`\`) with forward slashes (`/`).
Examples:
* Windows: `templates\app\aspnet-core\src\MyCompanyName.MyProjectName.DbMigrator\appsettings.json`
* Unix: `templates/app/aspnet-core/src/MyCompanyName.MyProjectName.DbMigrator/appsettings.json`
- Windows: `templates\app\aspnet-core\src\MyCompanyName.MyProjectName.DbMigrator\appsettings.json`
- Unix: `templates/app/aspnet-core/src/MyCompanyName.MyProjectName.DbMigrator/appsettings.json`
## Sample docker commands
## Sample Docker Commands
You need to install SQL Server and Redis. You can install these programs without docker, but my example uses docker containers. Your computer should have Docker Engine. Then open the terminal and execute the commands one by one.
For the SQL Server
You need SQL Server and Redis. You can install these programs without Docker, but the examples below use Docker containers. Your computer should have Docker Engine running. Then open a terminal and execute the commands.
```cmd
docker run -v sqlvolume:/var/opt/mssql -e 'ACCEPT_EULA=Y' -e "SA_PASSWORD=yourpassword" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2019-CU3-ubuntu-18.04
### SQL Server
```bash
docker run -v sqlvolume:/var/opt/mssql \
-e 'ACCEPT_EULA=Y' \
-e 'SA_PASSWORD=YourStrong!Passw0rd' \
-p 1433:1433 \
-d mcr.microsoft.com/mssql/server:2019-CU3-ubuntu-18.04
```
For the Redis
- Replace `YourStrong!Passw0rd` with a strong password that satisfies SQL Server password requirements.
- The `sqlvolume` named volume is used to persist database files.
### Redis
```cmd
docker run -p 6379:6379 -d redis
```bash
docker run -p 6379:6379 -d redis:latest
```
Then we are ready to download and execute the code.
After running the commands, you can use `docker ps` to verify that both containers are running.
Once the containers are ready, you can download the ABP source code and run the apps.
## Folder Structure
The app has a backend written in .net core (c#) and an angular app. It would help if you ran both of them.
The sample application has:
- A backend built with ASP.NET Core (C#).
- An Angular workspace managed by Nx.
You will run both the backend and the Angular dev app during development.
## Running the Backend App
The backend root path is `templates\app\aspnet-core`.
### 1. Configure the Connection Strings
## Running Backend App
If you are using the Dockerized SQL Server, update the connection strings to point to your Docker container. The configuration file is:
The path of the Backend app is “templates\app\aspnet-core.” If you want to work with dockerized SQL Server, you should change connection strings for running with docker. The path of the connection string is
`templates\app\aspnet-core\src\MyCompanyName.MyProjectName.DbMigrator\appsettings.json`.
- `templates\app\aspnet-core\src\MyCompanyName.MyProjectName.DbMigrator\appsettings.json`
Ensure that the connection string uses the correct server name (`localhost,1433` by default), user (`sa`), and your password.
### 2. Run the DbMigrator
The DbMigrator project creates the initial database schema and seed data.
```bash
cd templates/app/aspnet-core/src/MyCompanyName.MyProjectName.DbMigrator
dotnet run
```
Wait until the migration completes successfully.
### 3. Install Client-side Libraries
Before running the backend host, install the client-side libraries:
```bash
cd templates/app/aspnet-core
abp install-libs
```
This command restores the required client-side libraries for the backend.
### 4. Run the Backend Host
Go to the backend HTTP API host project folder. The exact project name may differ based on your template, but it will be similar to:
- `templates\app\aspnet-core\src\MyCompanyName.MyProjectName.HttpApi.HostWithIds`
Run the host:
```bash
cd templates/app/aspnet-core/src/MyCompanyName.MyProjectName.HttpApi.HostWithIds
dotnet run
```
After it starts, the backend API will be available on a localhost URL defined in the project (for example, `https://localhost:44305`, depending on your template).
## Running the Frontend (Angular Dev App)
The Angular workspace is under `npm\ng-packs`. It is an Nx workspace that contains both the dev app and the Angular UI packages.
- Dev app path: `npm\ng-packs\apps\dev-app`
- Package path: `npm\ng-packs\packages\`
The dev app uses local references to the packages under `packages`, so your library changes will be reflected immediately while the dev server is running.
### 1. Install Dependencies
From the dev app folder:
```bash
cd npm/ng-packs/apps/dev-app
yarn
# or, if you prefer npm:
# npm install
```
Choose one package manager (preferably `yarn` if that is what the repository uses) and stick with it.
### 2. Start the Dev Server
```bash
yarn start
# or:
# npm start
```
Before running the backend, you should run the Db migrator project. The DbMigrator created initial tables and values. The path of DbMigrator is `templates\app\aspnet-core\src\MyCompanyName.MyProjectName.DbMigrator`. Open a terminal in the path and execute the command `dotnet run` in terminal
This will start the Angular dev server (via Nx) and open the dev app in your browser. Ensure that the backend API is running so the dev app can connect to it.
One last step before the running the backend is installing client-side libraries. Go to `templates\app\aspnet-core`. Open a terminal in the path and execute the command `abp install-libs` in terminal
## Typical Contribution Workflow
Next step you should go to path of backend host project. The path is `templates\app\aspnet-core\src\MyCompanyName.MyProjectName.HttpApi.HostWithIds`. Open a terminal in the path and execute the command `dotnet run` in terminal
1. Start SQL Server and Redis (for example, using the Docker commands above).
2. Run DbMigrator to create and seed the database.
3. Run `abp install-libs` and start the backend HTTP API host.
4. Install dependencies and start the Angular dev app.
5. Make changes in the Angular UI packages under `npm\ng-packs\packages\`.
6. Run any relevant tests for the affected packages (for example, via Nx).
7. Commit your changes and open a pull request on GitHub, referencing the related issue.
Your backend should be running successfully
## Troubleshooting
## Running Frontend App
- **Backend cannot connect to SQL Server**
- Check that the SQL Server container is running (`docker ps`).
- Verify the connection string server/port and `SA_PASSWORD` value.
- **Angular app cannot reach the backend API**
- Confirm that the backend host is running and listening on the expected URL.
- Check the API base URL configuration in the dev app’s environment files.
- **Node or package manager version issues**
- Use an LTS version of Node.js.
- Consider using a version manager (like `nvm`) to match the version used in the project.
There is a demo app. The path of the demo app is `npm\ng-packs\apps\dev-app`. The demo app is connected to the packages with local references. Open the terminal in `npm\ng-packs\apps\dev-app` and execute `yarn` or `npm i` in terminal. After the package installed run `npm start` or `yarn start`.
## See Also
The repo uses Nx and packages connected with `local references`. The packages path is `npm\ng-packs\packages`
- [Contribution Guide](index.md)
- [ABP CLI](https://docs.abp.io/en/abp/latest/cli)

46
docs/en/docs-nav.json

@ -807,10 +807,6 @@
"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"
@ -1993,44 +1989,6 @@
}
]
},
{
"text": "Low-Code System",
"items": [
{
"text": "Overview",
"path": "low-code",
"isIndex": true
},
{
"text": "Attributes & Fluent API",
"path": "low-code/fluent-api.md"
},
{
"text": "model.json Structure",
"path": "low-code/model-json.md"
},
{
"text": "Reference Entities",
"path": "low-code/reference-entities.md"
},
{
"text": "Interceptors",
"path": "low-code/interceptors.md"
},
{
"text": "Scripting API",
"path": "low-code/scripting-api.md"
},
{
"text": "Custom Endpoints",
"path": "low-code/custom-endpoints.md"
},
{
"text": "Foreign Access",
"path": "low-code/foreign-access.md"
}
]
},
{
"text": "Solution Templates",
"items": [
@ -2615,6 +2573,10 @@
"text": "Language Management (Pro)",
"path": "modules/language-management.md"
},
{
"text": "Operation Rate Limiting (Pro)",
"path": "modules/operation-rate-limiting.md"
},
{
"text": "OpenIddict",
"isLazyExpandable": true,

4
docs/en/framework/api-development/auto-controllers.md

@ -70,7 +70,7 @@ Route is calculated based on some conventions:
* Continues with a **route path**. Default value is '**/app**' and can be configured as like below:
````csharp
Configure<AbpAspNetCoreMvcOptions>(options =>
PreConfigure<AbpAspNetCoreMvcOptions>(options =>
{
options.ConventionalControllers
.Create(typeof(BookStoreApplicationModule).Assembly, opts =>
@ -149,7 +149,7 @@ public class PersonAppService : ApplicationService
You can further filter classes to become an API controller by providing the `TypePredicate` option:
````csharp
services.Configure<AbpAspNetCoreMvcOptions>(options =>
PreConfigure<AbpAspNetCoreMvcOptions>(options =>
{
options.ConventionalControllers
.Create(typeof(BookStoreApplicationModule).Assembly, opts =>

4
docs/en/framework/infrastructure/background-jobs/tickerq.md

@ -95,13 +95,13 @@ public class CleanupJobs
public override Task OnPreApplicationInitializationAsync(ApplicationInitializationContext context)
{
var abpTickerQFunctionProvider = context.ServiceProvider.GetRequiredService<AbpTickerQFunctionProvider>();
abpTickerQFunctionProvider.Functions.TryAdd(nameof(CleanupJobs), (string.Empty, TickerTaskPriority.Normal, new TickerFunctionDelegate(async (cancellationToken, serviceProvider, tickerFunctionContext) =>
abpTickerQFunctionProvider.AddFunction(nameof(CleanupJobs), async (cancellationToken, serviceProvider, tickerFunctionContext) =>
{
var service = new CleanupJobs(); // Or get it from the serviceProvider
var request = await TickerRequestProvider.GetRequestAsync<string>(tickerFunctionContext, cancellationToken);
var genericContext = new TickerFunctionContext<string>(tickerFunctionContext, request);
await service.CleanupLogsAsync(genericContext, cancellationToken);
})));
}, TickerTaskPriority.Normal);
abpTickerQFunctionProvider.RequestTypes.TryAdd(nameof(CleanupJobs), (typeof(string).FullName, typeof(string)));
return Task.CompletedTask;
}

8
docs/en/framework/infrastructure/background-workers/tickerq.md

@ -83,13 +83,13 @@ public class CleanupJobs
public override Task OnPreApplicationInitializationAsync(ApplicationInitializationContext context)
{
var abpTickerQFunctionProvider = context.ServiceProvider.GetRequiredService<AbpTickerQFunctionProvider>();
abpTickerQFunctionProvider.Functions.TryAdd(nameof(CleanupJobs), (string.Empty, TickerTaskPriority.Normal, new TickerFunctionDelegate(async (cancellationToken, serviceProvider, tickerFunctionContext) =>
abpTickerQFunctionProvider.AddFunction(nameof(CleanupJobs), async (cancellationToken, serviceProvider, tickerFunctionContext) =>
{
var service = new CleanupJobs(); // Or get it from the serviceProvider
var request = await TickerRequestProvider.GetRequestAsync<string>(tickerFunctionContext, cancellationToken);
var genericContext = new TickerFunctionContext<string>(tickerFunctionContext, request);
await service.CleanupLogsAsync(genericContext, cancellationToken);
})));
}, TickerTaskPriority.Normal);
abpTickerQFunctionProvider.RequestTypes.TryAdd(nameof(CleanupJobs), (typeof(string).FullName, typeof(string)));
return Task.CompletedTask;
}
@ -112,11 +112,11 @@ await cronTickerManager.AddAsync(new CronTickerEntity
You can specify a cron expression instead of using `ICronTickerManager<CronTickerEntity>` to add a worker:
```csharp
abpTickerQFunctionProvider.Functions.TryAdd(nameof(CleanupJobs), (string.Empty, TickerTaskPriority.Normal, new TickerFunctionDelegate(async (cancellationToken, serviceProvider, tickerFunctionContext) =>
abpTickerQFunctionProvider.AddFunction(nameof(CleanupJobs), async (cancellationToken, serviceProvider, tickerFunctionContext) =>
{
var service = new CleanupJobs();
var request = await TickerRequestProvider.GetRequestAsync<string>(tickerFunctionContext, cancellationToken);
var genericContext = new TickerFunctionContext<string>(tickerFunctionContext, request);
await service.CleanupLogsAsync(genericContext, cancellationToken);
})));
}, TickerTaskPriority.Normal);
```

113
docs/en/framework/infrastructure/entity-cache.md

@ -26,7 +26,7 @@ public class Product : AggregateRoot<Guid>
public string Name { get; set; }
public string Description { get; set; }
public float Price { get; set; }
public decimal Price { get; set; }
public int StockCount { get; set; }
}
```
@ -72,7 +72,7 @@ public class ProductDto : EntityDto<Guid>
{
public string Name { get; set; }
public string Description { get; set; }
public float Price { get; set; }
public decimal Price { get; set; }
public int StockCount { get; set; }
}
```
@ -147,6 +147,115 @@ context.Services.AddEntityCache<Product, ProductDto, Guid>(
* Entity classes should be serializable/deserializable to/from JSON to be cached (because it's serialized to JSON when saving in the [Distributed Cache](../fundamentals/caching.md)). If your entity class is not serializable, you can consider using a cache-item/DTO class instead, as explained before.
* Entity Caching System is designed as **read-only**. You should use the standard [repository](../architecture/domain-driven-design/repositories.md) methods to manipulate the entity if you need to. If you need to manipulate (update) the entity, do not get it from the entity cache. Instead, read it from the repository, change it and update using the repository.
## Getting Multiple Entities
In addition to the single-entity methods `FindAsync` and `GetAsync`, the `IEntityCache` service provides batch retrieval methods for retrieving multiple entities at once.
### List-Based Batch Retrieval
`FindManyAsync` and `GetManyAsync` return results as a list, preserving the order of the given IDs (including duplicates):
```csharp
public class ProductAppService : ApplicationService, IProductAppService
{
private readonly IEntityCache<ProductDto, Guid> _productCache;
public ProductAppService(IEntityCache<ProductDto, Guid> productCache)
{
_productCache = productCache;
}
public async Task<List<ProductDto>> GetManyAsync(List<Guid> ids)
{
return await _productCache.GetManyAsync(ids);
}
public async Task<List<ProductDto?>> FindManyAsync(List<Guid> ids)
{
return await _productCache.FindManyAsync(ids);
}
}
```
* `GetManyAsync` throws `EntityNotFoundException` if any entity is not found for the given IDs.
* `FindManyAsync` returns a list where each entry corresponds to the given ID in the same order; an entry will be `null` if the entity was not found.
### Dictionary-Based Batch Retrieval
`FindManyAsDictionaryAsync` and `GetManyAsDictionaryAsync` return results as a dictionary keyed by entity ID, which is convenient when you need fast lookup by ID:
```csharp
public async Task<Dictionary<Guid, ProductDto?>> FindManyAsDictionaryAsync(List<Guid> ids)
{
return await _productCache.FindManyAsDictionaryAsync(ids);
}
public async Task<Dictionary<Guid, ProductDto>> GetManyAsDictionaryAsync(List<Guid> ids)
{
return await _productCache.GetManyAsDictionaryAsync(ids);
}
```
* `GetManyAsDictionaryAsync` throws `EntityNotFoundException` if any entity is not found for the given IDs.
* `FindManyAsDictionaryAsync` returns a dictionary where the value is `null` if the entity was not found for the corresponding key.
All batch methods internally use `IDistributedCache.GetOrAddManyAsync` to batch-fetch only the cache-missed entities from the database, making them more efficient than calling `FindAsync` or `GetAsync` in a loop.
## Custom Object Mapping
When you need full control over how an entity is mapped to a cache item, you can derive from `EntityCacheWithObjectMapper` and override the `MapToValue` method:
First, define the cache item class:
```csharp
public class ProductCacheDto
{
public Guid Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
```
Then, derive from `EntityCacheWithObjectMapper` and override `MapToValue`:
```csharp
public class ProductEntityCache :
EntityCacheWithObjectMapper<Product, ProductCacheDto, Guid>
{
public ProductEntityCache(
IReadOnlyRepository<Product, Guid> repository,
IDistributedCache<EntityCacheItemWrapper<ProductCacheDto>, Guid> cache,
IUnitOfWorkManager unitOfWorkManager,
IObjectMapper objectMapper)
: base(repository, cache, unitOfWorkManager, objectMapper)
{
}
protected override ProductCacheDto MapToValue(Product entity)
{
// Custom mapping logic here
return new ProductCacheDto
{
Id = entity.Id,
Name = entity.Name.ToUpperInvariant(),
Price = entity.Price
};
}
}
```
Register your custom cache class in the `ConfigureServices` method of your [module class](../architecture/modularity/basics.md):
```csharp
context.Services.ReplaceEntityCache<ProductEntityCache, Product, ProductCacheDto, Guid>(
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
```
> If no prior `AddEntityCache` registration exists for the same cache item type, `ReplaceEntityCache` will simply add the service instead of throwing an error.
## See Also
* [Distributed caching](../fundamentals/caching.md)

82
docs/en/framework/ui/angular/extensions-overall.md

@ -32,10 +32,78 @@ Using [ngx-datatable](https://github.com/swimlane/ngx-datatable) in extensible t
/>
````
* ` actionsText : ` ** Column name of action column. **Type** : string
* ` data : ` Items shows in your table. **Type** : Array<any>
* ` list : ` Instance of ListService. **Type** : ListService
* `actionsColumnWidth : ` Width of your action column. **Type** : number
* ` actionsTemplate : ` Template of the action when "click this button" or whatever. Generally ng-template. **Type** : TemplateRef<any>
* ` recordsTotal : ` Count of the record total. **Type** : number
* ` tableActivate : ` The Output(). A cell or row was focused via the keyboard or a mouse click. **Type** : EventEmitter()
| Input / Output | Description | Type |
|---------------------|----------------------------------------------------------------------------------------------|---------------------|
| `actionsText` | Column name of the action column. | `string` |
| `data` | Items shown in your table. | `Array<any>` |
| `list` | Instance of `ListService`. | `ListService` |
| `actionsColumnWidth`| Width of your action column. | `number` |
| `actionsTemplate` | Template of the action (for example, an `ng-template`). | `TemplateRef<any>` |
| `recordsTotal` | Total count of records. | `number` |
| `tableActivate` | Output fired when a cell or row is focused via keyboard or mouse click. | `EventEmitter` |
### Multiple Selection
The extensible table supports both single-row and multi-row selection. Use the `selectable` input to enable selection, and `selectionType` to control the selection mode.
````ts
<abp-extensible-table
[data]="items"
[recordsTotal]="totalCount"
[list]="list"
[selectable]="true"
[selectionType]="'multiClick'"
[selected]="selectedRows"
(selectionChange)="onSelectionChange($event)"
/>
````
When `selectionType` is `'single'`, each row displays a **radio button** and the header does not show a "select all" checkbox. For all other selection types (e.g. `'multiClick'`, `'checkbox'`), each row shows a **checkbox** and the header includes a "select all" checkbox.
| Input / Output | Description | Type | Default |
|--------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------|----------------|
| `selectable` | Enables the row selection column. | `boolean` | `false` |
| `selectionType` | Controls the selection mode. Accepts `SelectionType` values such as `'single'`, `'multi'`, `'multiClick'`, `'checkbox'`, or `'cell'`. | `SelectionType | string`| `'multiClick'` |
| `selected` | The currently selected rows. | `any[]` | `[]` |
| `selectionChange` | Output fired when the selection changes. | `EventEmitter<any[]>` | - |
### Infinite Scroll
The extensible table supports infinite scrolling as an alternative to pagination. When enabled, the table emits a `loadMore` event as the user scrolls near the bottom, allowing you to load additional data on demand. Pagination is hidden while infinite scroll is active.
````ts
<abp-extensible-table
[data]="items"
[recordsTotal]="totalCount"
[list]="list"
[infiniteScroll]="true"
[isLoading]="isLoading"
[tableHeight]="500"
[scrollThreshold]="10"
(loadMore)="onLoadMore()"
/>
````
In your component, append newly fetched data to the existing `items` array when `loadMore` fires:
````ts
onLoadMore(): void {
if (this.isLoading) return;
this.isLoading = true;
// fetch next page and append results
this.myService.getList({ skipCount: this.items.length, maxResultCount: 10 }).subscribe(result => {
this.items = [...this.items, ...result.items];
this.isLoading = false;
});
}
````
> **Note:** When `infiniteScroll` is `true`, set a fixed `tableHeight` so the table has a scrollable viewport. Pagination is automatically hidden.
| Input / Output | Description | Type | Default |
|-------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------|-----------|
| `infiniteScroll` | Enables infinite scroll mode (hides pagination). | `boolean` | `false` |
| `isLoading` | Indicates that more data is being fetched. Prevents duplicate `loadMore` events and shows a loading indicator. | `boolean` | `false` |
| `tableHeight` | Fixed height of the table in pixels when `infiniteScroll` is enabled. | `number` | - |
| `scrollThreshold` | Distance from the bottom (in pixels) at which `loadMore` is triggered. | `number` | `10` |
| `loadMore` | Output fired when the user scrolls near the bottom of the table (only when `infiniteScroll` is `true` and `isLoading` is `false`). | `EventEmitter<void>` | - |

20
docs/en/framework/ui/maui/index.md

@ -42,15 +42,29 @@ You can examine the [Users Page](#users-page) or any other pre-defined page to s
### Android
If you get the following error when connecting to the emulator or a physical phone, you need to set up port mapping.
If you get the following error when connecting to the emulator or a physical phone, you need to set up port mapping using the `adb` tool:
```
Cannot connect to the backend on localhost.
```
Open a command line terminal and run the `adb reverse` command to expose a port on your Android device to a port on your computer. For example:
**How to get and use `adb` tool:**
`adb reverse tcp:44305 tcp:44305`
- **Option 1: Install `adb` globally**
Download and install the [Android SDK Platform-Tools](https://developer.android.com/tools/releases/platform-tools) to get the [`adb`](https://developer.android.com/tools/adb) command-line tool.
- **Option 2: Use Visual Studio’s built-in `adb` command prompt**
If you are using Visual Studio, you can access the `adb` command prompt directly from the IDE:
![Android Adb Command Prompt](../../../images/adb-command-prompt.png)
> For more information on setting up your environment for Android development and debugging, refer to the [Microsoft MAUI Android device setup guide](https://learn.microsoft.com/en-us/dotnet/maui/android/device/setup).
**Port mapping command:**
Once `adb` is available, run the following command in your terminal (or Visual Studio's `adb` command prompt) to map the backend port to your Android device:
```bash
adb reverse tcp:44305 tcp:44305
```
> Replace `44305` with the port number your backend application is running on.
>

BIN
docs/en/images/adb-command-prompt.png

Binary file not shown.

79
docs/en/modules/ai-management/index.md

@ -44,6 +44,16 @@ abp add-package Volo.AIManagement.OpenAI
abp add-package Volo.AIManagement.Ollama
```
> [!IMPORTANT]
> If you use Ollama, make sure the Ollama server is installed and running, and that the models referenced by your workspace are already available locally. Before configuring an Ollama workspace, pull the chat model and any embedding model you plan to use. For example:
>
> ```bash
> ollama pull llama3.2
> ollama pull nomic-embed-text
> ```
>
> Replace the model names with the exact models you configure in the workspace. `nomic-embed-text` is an embedding-only model and can't be used as a chat model.
> [!TIP]
> You can install multiple provider packages to support different AI providers simultaneously in your workspaces.
@ -308,6 +318,14 @@ RAG requires an **embedder** and a **vector store** to be configured on the work
* **Embedder**: Converts documents and queries into vector embeddings. You can use any provider that supports embedding generation (e.g., OpenAI `text-embedding-3-small`, Ollama `nomic-embed-text`).
* **Vector Store**: Stores and retrieves vector embeddings. Supported providers: **MongoDb**, **Pgvector**, and **Qdrant**.
> [!IMPORTANT]
> If the workspace uses Ollama for chat or embeddings, the configured model names must exist in the local Ollama instance first. For example, if you configure `ModelName = "llama3.2"` and `EmbedderModelName = "nomic-embed-text"`, pull both models before using the workspace:
>
> ```bash
> ollama pull llama3.2
> ollama pull nomic-embed-text
> ```
### Configuring RAG on a Workspace
To enable RAG for a workspace, configure the embedder and vector store settings in the workspace edit page.
@ -432,6 +450,67 @@ The options class also provides helper methods:
> [!NOTE]
> Adding new file extensions also requires a matching content extractor to be registered for document processing. The built-in extractors support `.txt`, `.md`, and `.pdf` files.
#### Hosting-Level Upload Limits
`WorkspaceDataSourceOptions.MaxFileSize` controls the module-level validation, but your hosting stack may reject large uploads before the request reaches AI Management. If you increase `MaxFileSize`, make sure the underlying server and proxy limits are also updated.
Typical limits to review:
* **ASP.NET Core form/multipart limit** (`FormOptions.MultipartBodyLengthLimit`)
* **Kestrel request body limit** (`KestrelServerLimits.MaxRequestBodySize`)
* **IIS request filtering limit** (`maxAllowedContentLength`)
* **Reverse proxy limits** such as **Nginx** (`client_max_body_size`)
Example ASP.NET Core configuration:
```csharp
using Microsoft.AspNetCore.Http.Features;
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<WorkspaceDataSourceOptions>(options =>
{
options.MaxFileSize = 50 * 1024 * 1024;
});
Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 50 * 1024 * 1024;
});
}
```
```csharp
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 50 * 1024 * 1024;
});
```
Example IIS configuration in `web.config`:
```xml
<configuration>
<system.webServer>
<security>
<requestFiltering>
<requestLimits maxAllowedContentLength="52428800" />
</requestFiltering>
</security>
</system.webServer>
</configuration>
```
Example Nginx configuration:
```nginx
server {
client_max_body_size 50M;
}
```
If you are hosting behind another proxy or gateway (for example Apache, YARP, Azure App Gateway, Cloudflare, or Kubernetes ingress), ensure its request-body limit is also greater than or equal to the configured `MaxFileSize`.
## Permissions
The AI Management module defines the following permissions:

289
docs/en/framework/infrastructure/operation-rate-limiting.md → docs/en/modules/operation-rate-limiting.md

@ -1,11 +1,13 @@
````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."
"Description": "Learn how to use the Operation Rate Limiting module (Pro) in ABP to control the frequency of specific operations like SMS sending, login attempts, and resource-intensive tasks."
}
````
# Operation Rate Limiting
# Operation Rate Limiting Module (Pro)
> You must have an [ABP Team or a higher license](https://abp.io/pricing) to use this module.
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:
@ -15,15 +17,9 @@ ABP provides an operation rate limiting system that allows you to control the fr
> 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:
## How to Install
````bash
abp add-package Volo.Abp.OperationRateLimiting
````
> If you haven't done it yet, you first need to install the [ABP CLI](../../../cli).
This module is used by the [Account (Pro)](account-pro.md) module internally and comes pre-installed in the latest [startup templates](../solution-templates). So, no need to manually install it.
## Quick Start
@ -31,7 +27,7 @@ This section shows the basic usage of the operation rate limiting system with a
### Defining a Policy
First, define a rate limiting policy in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md):
First, define a rate limiting policy in the `ConfigureServices` method of your [module class](../framework/architecture/modularity/basics.md):
````csharp
Configure<AbpOperationRateLimitingOptions>(options =>
@ -62,7 +58,7 @@ public class SmsAppService : ApplicationService
_rateLimitChecker = rateLimitChecker;
}
public async Task SendCodeAsync(string phoneNumber)
public virtual async Task SendCodeAsync(string phoneNumber)
{
await _rateLimitChecker.CheckAsync("SendSmsCode", phoneNumber);
@ -78,9 +74,120 @@ public class SmsAppService : ApplicationService
That's the basic usage. The following sections explain each concept in detail.
## Declarative Usage (Attribute)
Instead of injecting `IOperationRateLimitingChecker` manually, you can use the `[OperationRateLimiting]` attribute to enforce a policy declaratively on Application Service methods or MVC Controller actions.
> **Application Services** are handled by the ABP interceptor (built into the Domain layer).
> **MVC Controllers** are handled by `AbpOperationRateLimitingActionFilter`, which is automatically registered when you reference the `Volo.Abp.OperationRateLimiting.AspNetCore` package.
### Applying to an Application Service
````csharp
public class SmsAppService : ApplicationService
{
[OperationRateLimiting("SendSmsCode")]
public virtual async Task SendCodeAsync([RateLimitingParameter] string phoneNumber)
{
// Rate limit is checked automatically before this line executes.
await _smsSender.SendAsync(phoneNumber, GenerateCode());
}
}
````
### Applying to an MVC Controller
````csharp
[Route("api/account")]
public class AccountController : AbpController
{
[HttpPost("send-sms-code")]
[OperationRateLimiting("SendSmsCode")]
public async Task<IActionResult> SendSmsCodeAsync([RateLimitingParameter] string phoneNumber)
{
// Rate limit is checked automatically before this line executes.
await _smsSender.SendAsync(phoneNumber, GenerateCode());
return Ok();
}
}
````
### Resolving the Parameter Value
The `[OperationRateLimiting]` attribute resolves `OperationRateLimitingContext.Parameter` automatically using the following priority order:
1. **`[RateLimitingParameter]`** — a method parameter marked with this attribute. Its `ToString()` value is used as the partition key.
2. **`IHasOperationRateLimitingParameter`** — a method parameter whose type implements this interface. The value returned by `GetPartitionParameter()` is used as the partition key.
3. **`null`** — no parameter is resolved; suitable for policies that use `PartitionByCurrentUser`, `PartitionByClientIp`, etc.
#### Using `[RateLimitingParameter]`
Mark a single parameter to use its value as the partition key:
````csharp
[OperationRateLimiting("SendSmsCode")]
public virtual async Task SendCodeAsync([RateLimitingParameter] string phoneNumber)
{
// partition key = phoneNumber
}
````
#### Using `IHasOperationRateLimitingParameter`
Implement the interface on an input DTO when the partition key is a property of the DTO:
````csharp
public class SendSmsCodeInput : IHasOperationRateLimitingParameter
{
public string PhoneNumber { get; set; }
public string Language { get; set; }
public string? GetPartitionParameter() => PhoneNumber;
}
````
````csharp
[OperationRateLimiting("SendSmsCode")]
public virtual async Task SendCodeAsync(SendSmsCodeInput input)
{
// partition key = input.GetPartitionParameter() = input.PhoneNumber
}
````
#### No Partition Parameter
If no parameter is marked and no DTO implements the interface, the policy is checked without a `Parameter` value. This is appropriate for policies that use `PartitionByCurrentUser`, `PartitionByClientIp`, or `PartitionByCurrentTenant`:
````csharp
// Policy uses PartitionByCurrentUser — no explicit parameter needed.
[OperationRateLimiting("GenerateReport")]
public virtual async Task<ReportDto> GenerateMonthlyReportAsync()
{
// Rate limit is checked per current user automatically.
}
````
> If the method has parameters but none is resolved, a **warning log** is emitted to help you catch misconfigured usages early.
### Applying to a Class
You can also place `[OperationRateLimiting]` on the class to apply it to **all public methods** of that class:
````csharp
[OperationRateLimiting("MyServiceLimit")]
public class MyAppService : ApplicationService
{
public virtual async Task MethodAAsync([RateLimitingParameter] string key) { ... }
public virtual async Task MethodBAsync([RateLimitingParameter] string key) { ... }
}
````
> A method-level attribute takes precedence over the class-level attribute.
## 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.
Policies are defined using `AbpOperationRateLimitingOptions` in the `ConfigureServices` method of your [module class](../framework/architecture/modularity/basics.md). Each policy has a unique name, one or more rules, and a partition strategy.
### Single-Rule Policies
@ -115,6 +222,78 @@ options.AddPolicy("Login", policy =>
> 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.
### Overriding an Existing Policy
If a reusable module (e.g., ABP's Account module) defines a policy with default rules, you have two ways to customize it in your own module's `ConfigureServices`.
**Option 1 — Full replacement with `AddPolicy`:**
Call `AddPolicy` with the same name. The last registration wins and completely replaces all rules:
````csharp
// In your application module — runs after the Account module
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.AddPolicy("Account.SendPasswordResetCode", policy =>
{
// Replaces all rules defined by the Account module for this policy
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3)
.PartitionByEmail());
});
});
````
> `AddPolicy` stores policies in a dictionary keyed by name, so calling it again with the same name fully replaces the previous policy and all its rules.
**Option 2 — Partial modification with `ConfigurePolicy`:**
Use `ConfigurePolicy` to modify an existing policy without replacing it entirely. The builder is pre-populated with the existing rules, so you only need to express what changes:
````csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
// Only override the error code, keeping the module's original rules
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.WithErrorCode("MyApp:SmsCodeLimit");
});
});
````
You can also add a rule on top of the existing ones:
````csharp
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
// Keep the module's per-email rule and add a per-IP rule on top
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20)
.PartitionByClientIp());
});
````
Or clear all inherited rules first and define entirely new ones using `ClearRules()`:
````csharp
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.ClearRules()
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3)
.PartitionByEmail();
});
````
`ConfigurePolicy` returns `AbpOperationRateLimitingOptions`, so you can chain multiple calls:
````csharp
options
.ConfigurePolicy("Account.SendPasswordResetCode", p => p.WithErrorCode("MyApp:SmsLimit"))
.ConfigurePolicy("Account.Login", p => p.WithErrorCode("MyApp:LoginLimit"));
````
> `ConfigurePolicy` throws `AbpException` if the policy name is not found. Use `AddPolicy` first (in the module that owns the policy), then `ConfigurePolicy` in downstream modules to customize it.
### Custom Error Code
By default, the exception uses the error code `Volo.Abp.OperationRateLimiting:010001`. You can override it per policy:
@ -195,14 +374,59 @@ Works the same way as `PartitionByEmail`: resolves from `context.Parameter` firs
### 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:
You can register a named custom resolver to generate the partition key. The resolver is an async function, so you can perform database queries or other I/O operations. Because the resolver is stored by name (not as an anonymous delegate), it can be serialized and managed from a UI or database.
**Step 1 — Register the resolver by name:**
````csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.AddPartitionKeyResolver("ByDevice", ctx =>
Task.FromResult($"{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}"));
});
````
**Step 2 — Reference it in a policy:**
````csharp
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100)
.PartitionBy("ByDevice");
````
You can also register and reference in one step (inline):
````csharp
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100)
.PartitionBy(ctx => Task.FromResult(
$"{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}"));
.PartitionBy("ByDevice", ctx =>
Task.FromResult($"{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}"));
````
> If you call `PartitionBy("name")` with a resolver name that hasn't been registered, an exception is thrown at configuration time (not at runtime), so typos are caught early.
To replace an existing resolver (e.g., in a downstream module), use `ReplacePartitionKeyResolver`:
````csharp
options.ReplacePartitionKeyResolver("ByDevice", ctx =>
Task.FromResult($"v2:{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}"));
````
### Named Rules (WithName)
By default, a rule's store key is derived from its `Duration`, `MaxCount`, and `PartitionType`. This means that if you change a rule's parameters (e.g., increase `maxCount` from 5 to 10), the counter resets because the key changes.
To keep a stable key across parameter changes, give the rule a name:
````csharp
policy.AddRule(rule => rule
.WithName("HourlyLimit")
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100)
.PartitionByCurrentUser());
````
When a name is set, it is used as the store key instead of the content-based descriptor. This is particularly useful when rules are managed from a database or UI — changing the `maxCount` or `duration` will not reset existing counters.
> Rule names must be unique within a policy. Duplicate names cause an exception at build time.
## 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.
@ -434,7 +658,7 @@ This module and ASP.NET Core's built-in [rate limiting middleware](https://learn
|---|---|---|
| **Level** | HTTP request pipeline | Application/domain code |
| **Scope** | All incoming requests | Specific business operations |
| **Usage** | Middleware (automatic) | Explicit `CheckAsync` calls |
| **Usage** | Middleware (automatic) | `[OperationRateLimiting]` attribute or 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.
@ -467,7 +691,7 @@ public class MyCustomStore : IOperationRateLimitingStore, ITransientDependency
}
````
ABP's [dependency injection](../../fundamentals/dependency-injection.md) system will automatically use your implementation since it replaces the default one.
ABP's [dependency injection](../framework/fundamentals/dependency-injection.md) system will automatically use your implementation since it replaces the default one.
### Custom Rule
@ -485,8 +709,33 @@ Replace `IOperationRateLimitingFormatter` to customize how time durations are di
Replace `IOperationRateLimitingPolicyProvider` to load policies from a database or external configuration source instead of the in-memory options.
When loading pre-built policies from an external source, use the `AddPolicy` overload that accepts an `OperationRateLimitingPolicy` object directly (bypassing the builder):
````csharp
options.AddPolicy(new OperationRateLimitingPolicy
{
Name = "DynamicPolicy",
Rules =
[
new OperationRateLimitingRuleDefinition
{
Name = "HourlyLimit",
Duration = TimeSpan.FromHours(1),
MaxCount = 100,
PartitionType = OperationRateLimitingPartitionType.CurrentUser
}
]
});
````
To remove a policy (e.g., when it is deleted from the database), use `RemovePolicy`:
````csharp
options.RemovePolicy("DynamicPolicy");
````
## 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)
* [Distributed Caching](../framework/fundamentals/caching.md)
* [Exception Handling](../framework/fundamentals/exception-handling.md)

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

@ -1,12 +1,29 @@
```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.3.0-rc.1
| Package | Old Version | New Version | PR |
|---------|-------------|-------------|-----|
| Microsoft.IdentityModel.JsonWebTokens | 8.14.0 | 8.16.0 | #25068 |
| Microsoft.IdentityModel.Protocols.OpenIdConnect | 8.14.0 | 8.16.0 | #25068 |
| Microsoft.IdentityModel.Tokens | 8.14.0 | 8.16.0 | #25068 |
| MongoDB.Driver | 3.7.0 | 3.7.1 | #25114 |
| System.IdentityModel.Tokens.Jwt | 8.14.0 | 8.16.0 | #25068 |
| TickerQ | 10.1.1 | 10.2.0 | #25091 |
| TickerQ.Dashboard | 10.1.1 | 10.2.0 | #25091 |
| TickerQ.EntityFrameworkCore | 10.1.1 | 10.2.0 | #25091 |
| TickerQ.Utilities | 10.1.1 | 10.2.0 | #25091 |
## 10.3.0-preview
| Package | Old Version | New Version | PR |
|---------|-------------|-------------|-----|
| OpenIddict.Abstractions | 7.2.0 | 7.3.0 | #25053 |
| OpenIddict.Core | 7.2.0 | 7.3.0 | #25053 |
| OpenIddict.Server.AspNetCore | 7.2.0 | 7.3.0 | #25053 |
| OpenIddict.Validation.AspNetCore | 7.2.0 | 7.3.0 | #25053 |
| OpenIddict.Validation.ServerIntegration | 7.2.0 | 7.3.0 | #25053 |
## 10.2.0-rc.1
| Package | Old Version | New Version | PR |

2
framework/Volo.Abp.slnx

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

5
framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/AbpBlazorClientHttpMessageHandler.cs

@ -53,7 +53,10 @@ public class AbpBlazorClientHttpMessageHandler : DelegatingHandler, ITransientDe
options.Type = UiPageProgressType.Info;
});
request.SetBrowserRequestStreamingEnabled(true);
if (request.RequestUri?.Scheme == Uri.UriSchemeHttps)
{
request.SetBrowserRequestStreamingEnabled(true);
}
await SetLanguageAsync(request, cancellationToken);
await SetAntiForgeryTokenAsync(request);
await SetTimeZoneAsync(request);

11
framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpInputTagHelperService.cs

@ -54,7 +54,7 @@ public class AbpInputTagHelperService : AbpTagHelperService<AbpInputTagHelper>
output.TagMode = TagMode.StartTagAndEndTag;
output.TagName = "div";
LeaveOnlyGroupAttributes(context, output);
if (!IsOutputHidden(output))
if (!IsInputHidden(context))
{
if (TagHelper.FloatingLabel && !isCheckBox)
{
@ -86,6 +86,7 @@ public class AbpInputTagHelperService : AbpTagHelperService<AbpInputTagHelper>
protected virtual async Task<(string, bool)> GetFormInputGroupAsHtmlAsync(TagHelperContext context, TagHelperOutput output)
{
var (inputTag, isCheckBox) = await GetInputTagHelperOutputAsync(context, output);
context.Items[nameof(IsOutputHidden)] = IsOutputHidden(inputTag);
var inputHtml = inputTag.Render(_encoder);
var label = await GetLabelAsHtmlAsync(context, output, inputTag, isCheckBox);
@ -124,7 +125,8 @@ public class AbpInputTagHelperService : AbpTagHelperService<AbpInputTagHelper>
protected virtual string SurroundInnerHtmlAndGet(TagHelperContext context, TagHelperOutput output, string innerHtml, bool isCheckbox)
{
var mb = TagHelper.AddMarginBottomClass ? (isCheckbox ? "mb-2" : "mb-3") : string.Empty;
var isHidden = IsInputHidden(context);
var mb = !isHidden && TagHelper.AddMarginBottomClass ? (isCheckbox ? "mb-2" : "mb-3") : string.Empty;
return "<div class=\"" + (isCheckbox ? $"custom-checkbox custom-control {mb} form-check" : $"{mb}") + "\">" +
Environment.NewLine + innerHtml + Environment.NewLine +
"</div>";
@ -516,6 +518,11 @@ public class AbpInputTagHelperService : AbpTagHelperService<AbpInputTagHelper>
return inputTag.Attributes.Any(a => a.Name.ToLowerInvariant() == "type" && a.Value.ToString()!.ToLowerInvariant() == "hidden");
}
protected virtual bool IsInputHidden(TagHelperContext context)
{
return context.Items.TryGetValue(nameof(IsOutputHidden), out var val) && val is true;
}
protected virtual string GetIdAttributeValue(TagHelperOutput inputTag)
{
var idAttr = inputTag.Attributes.FirstOrDefault(a => a.Name == "id");

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

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

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

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

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

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

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

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

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

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

59
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Validation/AbpValidationActionFilter.cs

@ -1,4 +1,5 @@
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
@ -39,27 +40,55 @@ public class AbpValidationActionFilter : IAsyncActionFilter, IAbpFilter, ITransi
return;
}
if (context.ActionDescriptor.GetMethodInfo().DeclaringType != context.Controller.GetType())
var effectiveMethod = GetEffectiveMethodInfo(context);
if (effectiveMethod != null)
{
var baseMethod = context.ActionDescriptor.GetMethodInfo();
var overrideMethod = context.Controller.GetType().GetMethods().FirstOrDefault(x =>
x.DeclaringType == context.Controller.GetType() &&
x.Name == baseMethod.Name &&
x.ReturnType == baseMethod.ReturnType &&
x.GetParameters().Select(p => p.ToString()).SequenceEqual(baseMethod.GetParameters().Select(p => p.ToString())));
if (overrideMethod != null)
if (ReflectionHelper.GetSingleAttributeOfMemberOrDeclaringTypeOrDefault<DisableValidationAttribute>(effectiveMethod) != null)
{
if (ReflectionHelper.GetSingleAttributeOfMemberOrDeclaringTypeOrDefault<DisableValidationAttribute>(overrideMethod) != null)
{
await next();
return;
}
await next();
return;
}
}
context.GetRequiredService<IModelStateValidator>().Validate(context.ModelState);
if (context.Controller is IValidationEnabled)
{
await ValidateActionArgumentsAsync(context, effectiveMethod);
}
await next();
}
protected virtual MethodInfo? GetEffectiveMethodInfo(ActionExecutingContext context)
{
var baseMethod = context.ActionDescriptor.GetMethodInfo();
if (baseMethod.DeclaringType == context.Controller.GetType())
{
return null;
}
return context.Controller.GetType().GetMethods().FirstOrDefault(x =>
x.DeclaringType == context.Controller.GetType() &&
x.Name == baseMethod.Name &&
x.ReturnType == baseMethod.ReturnType &&
x.GetParameters().Select(p => p.ToString()).SequenceEqual(baseMethod.GetParameters().Select(p => p.ToString())));
}
protected virtual async Task ValidateActionArgumentsAsync(ActionExecutingContext context, MethodInfo? effectiveMethod = null)
{
var methodInfo = effectiveMethod ?? context.ActionDescriptor.GetMethodInfo();
var parameterValues = methodInfo.GetParameters()
.Select(p => context.ActionArguments.TryGetValue(p.Name!, out var value) ? value : null)
.ToArray();
await context.GetRequiredService<IMethodInvocationValidator>().ValidateAsync(
new MethodInvocationValidationContext(
context.Controller,
methodInfo,
parameterValues
)
);
}
}

4
framework/src/Volo.Abp.BackgroundJobs.RabbitMQ/Volo/Abp/BackgroundJobs/RabbitMQ/JobQueue.cs

@ -190,10 +190,10 @@ public class JobQueue<TArgs> : IJobQueue<TArgs>
CorrelationId = CorrelationIdProvider.Get()
};
if (delay.HasValue)
if (delay.HasValue && delay.Value > TimeSpan.Zero)
{
routingKey = QueueConfiguration.DelayedQueueName;
basicProperties.Expiration = delay.Value.TotalMilliseconds.ToString(CultureInfo.InvariantCulture);
basicProperties.Expiration = ((long)Math.Ceiling(delay.Value.TotalMilliseconds)).ToString(CultureInfo.InvariantCulture);
}
if (ChannelAccessor != null)

18
framework/src/Volo.Abp.BackgroundJobs.TickerQ/Volo/Abp/BackgroundJobs/TickerQ/AbpBackgroundJobsTickerQModule.cs

@ -23,26 +23,14 @@ public class AbpBackgroundJobsTickerQModule : AbpModule
{
var abpBackgroundJobOptions = context.ServiceProvider.GetRequiredService<IOptions<AbpBackgroundJobOptions>>();
var abpBackgroundJobsTickerQOptions = context.ServiceProvider.GetRequiredService<IOptions<AbpBackgroundJobsTickerQOptions>>();
var tickerFunctionDelegates = new Dictionary<string, (string, TickerTaskPriority, TickerFunctionDelegate)>();
var requestTypes = new Dictionary<string, (string, Type)>();
var abpTickerQFunctionProvider = context.ServiceProvider.GetRequiredService<AbpTickerQFunctionProvider>();
foreach (var jobConfiguration in abpBackgroundJobOptions.Value.GetJobs())
{
var genericMethod = GetTickerFunctionDelegateMethod.MakeGenericMethod(jobConfiguration.ArgsType);
var tickerFunctionDelegate = (TickerFunctionDelegate)genericMethod.Invoke(null, [jobConfiguration.ArgsType])!;
var config = abpBackgroundJobsTickerQOptions.Value.GetConfigurationOrNull(jobConfiguration.JobType);
tickerFunctionDelegates.TryAdd(jobConfiguration.JobName, (string.Empty, config?.Priority ?? TickerTaskPriority.Normal, tickerFunctionDelegate));
requestTypes.TryAdd(jobConfiguration.JobName, (jobConfiguration.ArgsType.FullName, jobConfiguration.ArgsType)!);
}
var abpTickerQFunctionProvider = context.ServiceProvider.GetRequiredService<AbpTickerQFunctionProvider>();
foreach (var functionDelegate in tickerFunctionDelegates)
{
abpTickerQFunctionProvider.Functions.TryAdd(functionDelegate.Key, functionDelegate.Value);
}
foreach (var requestType in requestTypes)
{
abpTickerQFunctionProvider.RequestTypes.TryAdd(requestType.Key, requestType.Value);
abpTickerQFunctionProvider.AddFunction(jobConfiguration.JobName, tickerFunctionDelegate, config?.Priority ?? TickerTaskPriority.Normal, config?.MaxConcurrency ?? 0);
abpTickerQFunctionProvider.RequestTypes.TryAdd(jobConfiguration.JobName, (jobConfiguration.ArgsType.FullName, jobConfiguration.ArgsType)!);
}
}

2
framework/src/Volo.Abp.BackgroundJobs.TickerQ/Volo/Abp/BackgroundJobs/TickerQ/AbpBackgroundJobsTimeTickerConfiguration.cs

@ -10,5 +10,7 @@ public class AbpBackgroundJobsTimeTickerConfiguration
public TickerTaskPriority? Priority { get; set; }
public int? MaxConcurrency { get; set; }
public RunCondition? RunCondition { get; set; }
}

2
framework/src/Volo.Abp.BackgroundWorkers.TickerQ/Volo/Abp/BackgroundWorkers/TickerQ/AbpBackgroundWorkersCronTickerConfiguration.cs

@ -9,4 +9,6 @@ public class AbpBackgroundWorkersCronTickerConfiguration
public int[]? RetryIntervals { get; set; }
public TickerTaskPriority? Priority { get; set; }
public int? MaxConcurrency { get; set; }
}

4
framework/src/Volo.Abp.BackgroundWorkers.TickerQ/Volo/Abp/BackgroundWorkers/TickerQ/AbpTickerQBackgroundWorkerManager.cs

@ -53,11 +53,11 @@ public class AbpTickerQBackgroundWorkerManager : BackgroundWorkerManager, ISingl
var name = BackgroundWorkerNameAttribute.GetNameOrNull(worker.GetType()) ?? worker.GetType().FullName;
var config = Options.GetConfigurationOrNull(ProxyHelper.GetUnProxiedType(worker));
AbpTickerQFunctionProvider.Functions.TryAdd(name!, (string.Empty, config?.Priority ?? TickerTaskPriority.LongRunning, async (tickerQCancellationToken, serviceProvider, tickerFunctionContext) =>
AbpTickerQFunctionProvider.AddFunction(name!, async (tickerQCancellationToken, serviceProvider, tickerFunctionContext) =>
{
var workerInvoker = new AbpTickerQPeriodicBackgroundWorkerInvoker(worker, serviceProvider);
await workerInvoker.DoWorkAsync(tickerFunctionContext, tickerQCancellationToken);
}));
}, config?.Priority ?? TickerTaskPriority.LongRunning, config?.MaxConcurrency ?? 0);
AbpTickerQBackgroundWorkersProvider.BackgroundWorkers.Add(name!, new AbpTickerQCronBackgroundWorker
{

1
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/AbpCliCoreModule.cs

@ -80,6 +80,7 @@ public class AbpCliCoreModule : AbpModule
options.Commands[RecreateInitialMigrationCommand.Name] = typeof(RecreateInitialMigrationCommand);
options.Commands[GenerateRazorPage.Name] = typeof(GenerateRazorPage);
options.Commands[McpCommand.Name] = typeof(McpCommand);
options.Commands[GenerateJwksCommand.Name] = typeof(GenerateJwksCommand);
options.DisabledModulesToAddToSolution.Add("Volo.Abp.LeptonXTheme.Pro");
options.DisabledModulesToAddToSolution.Add("Volo.Abp.LeptonXTheme.Lite");

173
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/GenerateJwksCommand.cs

@ -0,0 +1,173 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Volo.Abp.Cli.Args;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.Cli.Commands;
public class GenerateJwksCommand : IConsoleCommand, ITransientDependency
{
public const string Name = "generate-jwks";
public ILogger<GenerateJwksCommand> Logger { get; set; }
public GenerateJwksCommand()
{
Logger = NullLogger<GenerateJwksCommand>.Instance;
}
public Task ExecuteAsync(CommandLineArgs commandLineArgs)
{
var outputDir = commandLineArgs.Options.GetOrNull("output", "o")
?? Directory.GetCurrentDirectory();
var keySizeStr = commandLineArgs.Options.GetOrNull("key-size", "s") ?? "2048";
var alg = commandLineArgs.Options.GetOrNull("alg") ?? "RS256";
var kid = commandLineArgs.Options.GetOrNull("kid") ?? Guid.NewGuid().ToString("N");
var filePrefix = commandLineArgs.Options.GetOrNull("file", "f") ?? "jwks";
if (!int.TryParse(keySizeStr, out var keySize) || (keySize != 2048 && keySize != 4096))
{
Logger.LogError("Invalid key size '{0}'. Supported values: 2048, 4096.", keySizeStr);
return Task.CompletedTask;
}
if (!IsValidAlgorithm(alg))
{
Logger.LogError("Invalid algorithm '{0}'. Supported values: RS256, RS384, RS512, PS256, PS384, PS512.", alg);
return Task.CompletedTask;
}
if (!Directory.Exists(outputDir))
{
Directory.CreateDirectory(outputDir);
}
Logger.LogInformation("Generating RSA {0}-bit key pair (algorithm: {1})...", keySize, alg);
using var rsa = RSA.Create();
rsa.KeySize = keySize;
var jwksJson = BuildJwksJson(rsa, alg, kid);
var privateKeyPem = ExportPrivateKeyPem(rsa);
var jwksFilePath = Path.Combine(outputDir, $"{filePrefix}.json");
var privateKeyFilePath = Path.Combine(outputDir, $"{filePrefix}-private.pem");
File.WriteAllText(jwksFilePath, jwksJson, Encoding.UTF8);
File.WriteAllText(privateKeyFilePath, privateKeyPem, Encoding.UTF8);
Logger.LogInformation("");
Logger.LogInformation("Generated files:");
Logger.LogInformation(" JWKS (public key) : {0}", jwksFilePath);
Logger.LogInformation(" Private key (PEM) : {0}", privateKeyFilePath);
Logger.LogInformation("");
Logger.LogInformation("JWKS content (paste this into the ABP OpenIddict application's 'JSON Web Key Set' field):");
Logger.LogInformation("");
Logger.LogInformation("{0}", jwksJson);
Logger.LogInformation("");
Logger.LogInformation("IMPORTANT: Keep the private key file safe. Never share it or commit it to source control.");
Logger.LogInformation(" The JWKS file contains only the public key and is safe to share.");
return Task.CompletedTask;
}
private static string BuildJwksJson(RSA rsa, string alg, string kid)
{
var parameters = rsa.ExportParameters(false);
var n = Base64UrlEncode(parameters.Modulus);
var e = Base64UrlEncode(parameters.Exponent);
using var stream = new System.IO.MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
writer.WriteStartObject();
writer.WriteStartArray("keys");
writer.WriteStartObject();
writer.WriteString("kty", "RSA");
writer.WriteString("use", "sig");
writer.WriteString("kid", kid);
writer.WriteString("alg", alg);
writer.WriteString("n", n);
writer.WriteString("e", e);
writer.WriteEndObject();
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
return Encoding.UTF8.GetString(stream.ToArray());
}
private static string ExportPrivateKeyPem(RSA rsa)
{
#if NET5_0_OR_GREATER
return rsa.ExportPkcs8PrivateKeyPem();
#elif NETSTANDARD2_0
// RSA.ExportPkcs8PrivateKey() was introduced in .NET Standard 2.1.
// The ABP CLI always runs on .NET 5+, so this path is never reached at runtime.
throw new PlatformNotSupportedException("Private key export requires .NET Standard 2.1 or later.");
#else
var privateKeyBytes = rsa.ExportPkcs8PrivateKey();
var base64 = Convert.ToBase64String(privateKeyBytes, Base64FormattingOptions.InsertLineBreaks);
return $"-----BEGIN PRIVATE KEY-----\n{base64}\n-----END PRIVATE KEY-----";
#endif
}
private static string Base64UrlEncode(byte[] input)
{
return Convert.ToBase64String(input)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static bool IsValidAlgorithm(string alg)
{
return alg == "RS256" || alg == "RS384" || alg == "RS512" ||
alg == "PS256" || alg == "PS384" || alg == "PS512";
}
public string GetUsageInfo()
{
var sb = new StringBuilder();
sb.AppendLine("");
sb.AppendLine("Usage:");
sb.AppendLine(" abp generate-jwks [options]");
sb.AppendLine("");
sb.AppendLine("Options:");
sb.AppendLine(" -o|--output <dir> Output directory (default: current directory)");
sb.AppendLine(" -s|--key-size <size> RSA key size: 2048 or 4096 (default: 2048)");
sb.AppendLine(" --alg <alg> Algorithm: RS256, RS384, RS512, PS256, PS384, PS512 (default: RS256)");
sb.AppendLine(" --kid <id> Key ID (kid) - auto-generated if not specified");
sb.AppendLine(" -f|--file <prefix> Output file name prefix (default: jwks)");
sb.AppendLine(" Generates: <prefix>.json (JWKS) and <prefix>-private.pem (private key)");
sb.AppendLine("");
sb.AppendLine("Examples:");
sb.AppendLine(" abp generate-jwks");
sb.AppendLine(" abp generate-jwks --alg RS512 --key-size 4096");
sb.AppendLine(" abp generate-jwks -o ./keys -f myapp");
sb.AppendLine("");
sb.AppendLine("Description:");
sb.AppendLine(" Generates an RSA key pair for use with OpenIddict private_key_jwt client authentication.");
sb.AppendLine(" The JWKS file (public key) should be pasted into the ABP OpenIddict application's");
sb.AppendLine(" 'JSON Web Key Set' field in the management UI.");
sb.AppendLine(" The private key PEM file should be kept secure and used by the client application");
sb.AppendLine(" to sign JWT assertions when authenticating to the token endpoint.");
sb.AppendLine("");
sb.AppendLine("See the documentation for more info: https://abp.io/docs/latest/cli");
return sb.ToString();
}
public static string GetShortDescription()
{
return "Generates an RSA key pair (JWKS + private key) for OpenIddict private_key_jwt authentication.";
}
}

88
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheBase.cs

@ -1,4 +1,6 @@
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Volo.Abp.Caching;
using Volo.Abp.Data;
using Volo.Abp.Domain.Entities.Events;
@ -14,6 +16,7 @@ public abstract class EntityCacheBase<TEntity, TEntityCacheItem, TKey> :
ILocalEventHandler<EntityChangedEventData<TEntity>>
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
protected IReadOnlyRepository<TEntity, TKey> Repository { get; }
protected IDistributedCache<EntityCacheItemWrapper<TEntityCacheItem>, TKey> Cache { get; }
@ -44,6 +47,20 @@ public abstract class EntityCacheBase<TEntity, TEntityCacheItem, TKey> :
}))?.Value;
}
public virtual async Task<List<TEntityCacheItem?>> FindManyAsync(IEnumerable<TKey> ids)
{
var idArray = ids.ToArray();
var cacheItemDict = await GetCacheItemDictionaryAsync(idArray.Distinct().ToArray());
return idArray
.Select(id => cacheItemDict.TryGetValue(id, out var item) ? item : null)
.ToList();
}
public virtual async Task<Dictionary<TKey, TEntityCacheItem?>> FindManyAsDictionaryAsync(IEnumerable<TKey> ids)
{
return await GetCacheItemDictionaryAsync(ids.Distinct().ToArray());
}
public virtual async Task<TEntityCacheItem> GetAsync(TKey id)
{
return (await Cache.GetOrAddAsync(
@ -59,6 +76,75 @@ public abstract class EntityCacheBase<TEntity, TEntityCacheItem, TKey> :
}))!.Value!;
}
public virtual async Task<List<TEntityCacheItem>> GetManyAsync(IEnumerable<TKey> ids)
{
var idArray = ids.ToArray();
var cacheItemDict = await GetCacheItemDictionaryAsync(idArray.Distinct().ToArray());
return idArray
.Select(id =>
{
if (!cacheItemDict.TryGetValue(id, out var item) || item == null)
{
throw new EntityNotFoundException<TEntity>(id);
}
return item;
})
.ToList();
}
public virtual async Task<Dictionary<TKey, TEntityCacheItem>> GetManyAsDictionaryAsync(IEnumerable<TKey> ids)
{
var distinctIds = ids.Distinct().ToArray();
var cacheItemDict = await GetCacheItemDictionaryAsync(distinctIds);
var result = new Dictionary<TKey, TEntityCacheItem>();
foreach (var id in distinctIds)
{
if (!cacheItemDict.TryGetValue(id, out var item) || item == null)
{
throw new EntityNotFoundException<TEntity>(id);
}
result[id] = item;
}
return result;
}
protected virtual async Task<Dictionary<TKey, TEntityCacheItem?>> GetCacheItemDictionaryAsync(TKey[] distinctIds)
{
var cacheItems = await GetOrAddManyCacheItemsAsync(distinctIds);
return cacheItems.ToDictionary(x => x.Key, x => x.Value?.Value);
}
protected virtual async Task<KeyValuePair<TKey, EntityCacheItemWrapper<TEntityCacheItem>?>[]> GetOrAddManyCacheItemsAsync(TKey[] ids)
{
return await Cache.GetOrAddManyAsync(
ids,
async missingKeys =>
{
if (HasObjectExtensionInfo())
{
Repository.EnableTracking();
}
var missingKeyArray = missingKeys.ToArray();
var entities = await Repository.GetListAsync(
x => missingKeyArray.Contains(x.Id),
includeDetails: true
);
var entityDict = entities.ToDictionary(e => e.Id);
return missingKeyArray
.Select(key =>
{
entityDict.TryGetValue(key, out var entity);
return new KeyValuePair<TKey, EntityCacheItemWrapper<TEntityCacheItem>>(
key,
MapToCacheItem(entity)!
);
})
.ToList();
});
}
protected virtual bool HasObjectExtensionInfo()
{
return typeof(IHasExtraProperties).IsAssignableFrom(typeof(TEntity)) &&

30
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheServiceCollectionExtensions.cs

@ -14,6 +14,7 @@ public static class EntityCacheServiceCollectionExtensions
this IServiceCollection services,
DistributedCacheEntryOptions? cacheOptions = null)
where TEntity : Entity<TKey>
where TKey : notnull
{
services.TryAddTransient<IEntityCache<TEntity, TKey>, EntityCacheWithoutCacheItem<TEntity, TKey>>();
services.TryAddTransient<EntityCacheWithoutCacheItem<TEntity, TKey>>();
@ -36,6 +37,7 @@ public static class EntityCacheServiceCollectionExtensions
DistributedCacheEntryOptions? cacheOptions = null)
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
services.TryAddTransient<IEntityCache<TEntityCacheItem, TKey>, EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey>>();
services.TryAddTransient<EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey>>();
@ -53,6 +55,7 @@ public static class EntityCacheServiceCollectionExtensions
DistributedCacheEntryOptions? cacheOptions = null)
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
services.TryAddTransient<IEntityCache<TEntityCacheItem, TKey>, EntityCacheWithObjectMapperContext<TObjectMapperContext, TEntity, TEntityCacheItem, TKey>>();
services.TryAddTransient<EntityCacheWithObjectMapperContext<TObjectMapperContext, TEntity, TEntityCacheItem, TKey>>();
@ -65,6 +68,33 @@ public static class EntityCacheServiceCollectionExtensions
return services;
}
public static IServiceCollection ReplaceEntityCache<TEntityCache, TEntity, TEntityCacheItem, TKey>(
this IServiceCollection services,
DistributedCacheEntryOptions? cacheOptions = null)
where TEntityCache : EntityCacheBase<TEntity, TEntityCacheItem, TKey>
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
services.Replace(ServiceDescriptor.Transient<IEntityCache<TEntityCacheItem, TKey>, TEntityCache>());
services.TryAddTransient<TEntityCache>();
services.Configure<AbpDistributedCacheOptions>(options =>
{
options.ConfigureCache<EntityCacheItemWrapper<TEntityCacheItem>>(cacheOptions ?? GetDefaultCacheOptions());
});
if (typeof(TEntity) == typeof(TEntityCacheItem))
{
services.Configure<AbpSystemTextJsonSerializerModifiersOptions>(options =>
{
options.Modifiers.Add(new AbpIncludeNonPublicPropertiesModifiers<TEntity, TKey>().CreateModifyAction(x => x.Id));
});
}
return services;
}
private static DistributedCacheEntryOptions GetDefaultCacheOptions()
{
return new DistributedCacheEntryOptions {

10
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapper.cs

@ -10,6 +10,7 @@ public class EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey> :
EntityCacheBase<TEntity, TEntityCacheItem, TKey>
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
protected IObjectMapper ObjectMapper { get; }
@ -30,11 +31,16 @@ public class EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey> :
return null;
}
return new EntityCacheItemWrapper<TEntityCacheItem>(MapToValue(entity));
}
protected virtual TEntityCacheItem MapToValue(TEntity entity)
{
if (typeof(TEntity) == typeof(TEntityCacheItem))
{
return new EntityCacheItemWrapper<TEntityCacheItem>(entity.As<TEntityCacheItem>());
return entity.As<TEntityCacheItem>();
}
return new EntityCacheItemWrapper<TEntityCacheItem>(ObjectMapper.Map<TEntity, TEntityCacheItem>(entity));
return ObjectMapper.Map<TEntity, TEntityCacheItem>(entity);
}
}

1
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapperContext.cs

@ -9,6 +9,7 @@ public class EntityCacheWithObjectMapperContext<TObjectMapperContext, TEntity, T
EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey>
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
public EntityCacheWithObjectMapperContext(
IReadOnlyRepository<TEntity, TKey> repository,

1
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithoutCacheItem.cs

@ -7,6 +7,7 @@ namespace Volo.Abp.Domain.Entities.Caching;
public class EntityCacheWithoutCacheItem<TEntity, TKey> :
EntityCacheBase<TEntity, TEntity, TKey>
where TEntity : Entity<TKey>
where TKey : notnull
{
public EntityCacheWithoutCacheItem(
IReadOnlyRepository<TEntity, TKey> repository,

38
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/IEntityCache.cs

@ -1,21 +1,49 @@
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Threading.Tasks;
using JetBrains.Annotations;
namespace Volo.Abp.Domain.Entities.Caching;
public interface IEntityCache<TEntityCacheItem, in TKey>
public interface IEntityCache<TEntityCacheItem, TKey>
where TEntityCacheItem : class
where TKey : notnull
{
/// <summary>
/// Gets the entity with given <paramref name="id"/>,
/// or returns null if the entity was not found.
/// </summary>
Task<TEntityCacheItem?> FindAsync(TKey id);
/// <summary>
/// Gets multiple entities with the given <paramref name="ids"/>.
/// Returns a list where each entry corresponds to the given id in the same order.
/// An entry will be null if the entity was not found for the corresponding id.
/// </summary>
Task<List<TEntityCacheItem?>> FindManyAsync(IEnumerable<TKey> ids);
/// <summary>
/// Gets multiple entities with the given <paramref name="ids"/> as a dictionary keyed by id.
/// An entry will be null if the entity was not found for the corresponding id.
/// </summary>
Task<Dictionary<TKey, TEntityCacheItem?>> FindManyAsDictionaryAsync(IEnumerable<TKey> ids);
/// <summary>
/// Gets the entity with given <paramref name="id"/>,
/// or throws <see cref="EntityNotFoundException"/> if the entity was not found.
/// </summary>
[ItemNotNull]
[ItemNotNull]
Task<TEntityCacheItem> GetAsync(TKey id);
}
/// <summary>
/// Gets multiple entities with the given <paramref name="ids"/>.
/// Returns a list where each entry corresponds to the given id in the same order.
/// Throws <see cref="EntityNotFoundException"/> if any entity was not found.
/// </summary>
Task<List<TEntityCacheItem>> GetManyAsync(IEnumerable<TKey> ids);
/// <summary>
/// Gets multiple entities with the given <paramref name="ids"/> as a dictionary keyed by id.
/// Throws <see cref="EntityNotFoundException"/> if any entity was not found.
/// </summary>
Task<Dictionary<TKey, TEntityCacheItem>> GetManyAsDictionaryAsync(IEnumerable<TKey> ids);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Loading…
Cancel
Save