@ -0,0 +1,8 @@ |
|||||
|
{ |
||||
|
"permissions": { |
||||
|
"allow": [ |
||||
|
"Bash(yarn nx g:*)", |
||||
|
"Bash(npx vitest:*)" |
||||
|
] |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,151 @@ |
|||||
|
# ABP AI Rules |
||||
|
|
||||
|
This folder contains AI rules (Cursor `.mdc` format) for ABP based solutions. These rules help AI assistants understand ABP-specific patterns, conventions, and best practices when working with ABP-based applications. |
||||
|
|
||||
|
## Purpose |
||||
|
|
||||
|
This folder serves as a central repository for ABP-specific AI rules. The community can contribute, improve, and maintain these rules collaboratively. |
||||
|
|
||||
|
When you create a new ABP solution, these rules are included in your project based on your configuration. This provides AI assistants with ABP-specific context, helping them generate code that follows ABP conventions. |
||||
|
|
||||
|
> **Important**: These rules are ABP-specific. They don't cover general .NET or ASP.NET Core patterns—AI assistants already know those. Instead, they focus on ABP's unique architecture, module system, and conventions. |
||||
|
|
||||
|
## How Rules Work |
||||
|
|
||||
|
Large language models don't retain memory between completions. Rules provide persistent, reusable context at the prompt level. |
||||
|
|
||||
|
When applied, rule contents are included at the start of the model context. This gives the AI consistent guidance for generating code, interpreting edits, or helping with workflows. |
||||
|
|
||||
|
## Mini Glossary (ABP Terms) |
||||
|
|
||||
|
- **Application service**: Use-case orchestration (ABP’s primary “business API” surface). Usually exposed remotely via Auto API Controllers or explicit controllers. |
||||
|
- **Auto API Controllers**: ABP can auto-generate HTTP endpoints from `IApplicationService` contracts. |
||||
|
- **Client proxy**: Generated client-side code (Angular/JS/C#) to call remote application services. |
||||
|
- **Integration service (microservices)**: Application-service-like contract intended for **service-to-service** communication; typically exposed separately and consumed via generated C# proxies. |
||||
|
- **Domain vs Application**: Domain holds business rules/invariants; Application coordinates domain + infrastructure and returns DTOs. |
||||
|
|
||||
|
## File Structure |
||||
|
|
||||
|
``` |
||||
|
ai-rules/ |
||||
|
├── README.md |
||||
|
├── common/ # Rules for all ABP projects |
||||
|
│ ├── abp-core.mdc # Core ABP conventions (alwaysApply: true) |
||||
|
│ ├── ddd-patterns.mdc # DDD patterns (Entity, AggregateRoot, Repository) |
||||
|
│ ├── application-layer.mdc # Application services, DTOs, validation |
||||
|
│ ├── authorization.mdc # Permissions and authorization |
||||
|
│ ├── multi-tenancy.mdc # Multi-tenant entities and data isolation |
||||
|
│ ├── infrastructure.mdc # Settings, Features, Caching, Events, Jobs |
||||
|
│ ├── dependency-rules.mdc # Layer dependencies and guardrails |
||||
|
│ ├── development-flow.mdc # Development workflow |
||||
|
│ └── cli-commands.mdc # ABP CLI commands reference |
||||
|
├── ui/ # UI-specific rules (applied by globs) |
||||
|
│ ├── blazor.mdc # Blazor UI patterns |
||||
|
│ ├── angular.mdc # Angular UI patterns |
||||
|
│ └── mvc.mdc # MVC/Razor Pages patterns |
||||
|
├── data/ # Data layer rules (applied by globs) |
||||
|
│ ├── ef-core.mdc # Entity Framework Core patterns |
||||
|
│ └── mongodb.mdc # MongoDB patterns |
||||
|
├── testing/ # Testing rules |
||||
|
│ └── patterns.mdc # Unit and integration test patterns |
||||
|
└── template-specific/ # Template-specific rules |
||||
|
├── app-nolayers.mdc # Single-layer app template |
||||
|
├── module.mdc # Module template |
||||
|
└── microservice.mdc # Microservice template |
||||
|
``` |
||||
|
|
||||
|
### Rule Format |
||||
|
|
||||
|
Each rule is a markdown file with frontmatter metadata: |
||||
|
|
||||
|
```markdown |
||||
|
--- |
||||
|
description: "Describes when this rule should apply - used by AI to decide relevance" |
||||
|
globs: "src/**/*.cs" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# Rule Title |
||||
|
|
||||
|
Your rule content here... |
||||
|
``` |
||||
|
|
||||
|
### Frontmatter Properties |
||||
|
|
||||
|
| Property | Description | |
||||
|
|----------|-------------| |
||||
|
| `description` | Brief description of what the rule covers. Used by AI to determine relevance. | |
||||
|
| `globs` | File patterns that trigger this rule (e.g., `**/*.cs`, `*.Domain/**`). | |
||||
|
| `alwaysApply` | If `true`, rule is always included. If `false`, AI decides based on context. | |
||||
|
|
||||
|
### Rule Types |
||||
|
|
||||
|
| Type | When Applied | |
||||
|
|------|--------------| |
||||
|
| **Always Apply** | Every chat session (`alwaysApply: true`) | |
||||
|
| **Apply Intelligently** | When AI decides it's relevant based on `description` | |
||||
|
| **Apply to Specific Files** | When file matches `globs` pattern | |
||||
|
| **Apply Manually** | When @-mentioned in chat (e.g., `@my-rule`) | |
||||
|
|
||||
|
## Rule Categories |
||||
|
|
||||
|
### Common Rules |
||||
|
Core ABP patterns that apply to all DDD-based templates (app, module, microservice): |
||||
|
- `abp-core.mdc` - Always applied, covers module system, DI conventions, base classes |
||||
|
- `ddd-patterns.mdc` - Entity, AggregateRoot, Repository, Domain Services |
||||
|
- `application-layer.mdc` - Application services, DTOs, validation, error handling |
||||
|
- `authorization.mdc` - Permission system and authorization |
||||
|
- `infrastructure.mdc` - Settings, Features, Caching, Events, Background Jobs |
||||
|
- `dependency-rules.mdc` - Layer dependencies and project structure |
||||
|
- `development-flow.mdc` - Development workflow for adding features |
||||
|
|
||||
|
### UI Rules (Applied by Globs) |
||||
|
- `blazor.mdc` - Applied to `**/*.razor`, `**/Blazor/**/*.cs` |
||||
|
- `angular.mdc` - Applied to `**/angular/**/*.ts` |
||||
|
- `mvc.mdc` - Applied to `**/*.cshtml`, `**/Pages/**/*.cs` |
||||
|
|
||||
|
### Data Rules (Applied by Globs) |
||||
|
- `ef-core.mdc` - Applied to `**/*.EntityFrameworkCore/**/*.cs` |
||||
|
- `mongodb.mdc` - Applied to `**/*.MongoDB/**/*.cs` |
||||
|
|
||||
|
### Template-Specific Rules |
||||
|
- `app-nolayers.mdc` - For single-layer web application template |
||||
|
- `module.mdc` - For reusable module template |
||||
|
- `microservice.mdc` - For microservice template |
||||
|
|
||||
|
## Best Practices |
||||
|
|
||||
|
Good rules are focused, actionable, and scoped: |
||||
|
|
||||
|
- **Keep rules under 500 lines** - Split large rules into multiple, composable rules |
||||
|
- **Provide concrete examples** - Reference actual files or include code snippets |
||||
|
- **Be specific, not vague** - Write rules like clear internal documentation |
||||
|
- **Reference files instead of copying** - This keeps rules short and prevents staleness |
||||
|
- **Start simple** - Add rules only when you notice AI making the same mistake repeatedly |
||||
|
|
||||
|
## What to Avoid |
||||
|
|
||||
|
- **Copying entire style guides**: Use a linter instead. AI already knows common style conventions. |
||||
|
- **Documenting every possible command**: AI knows common tools like `dotnet` and `npm`. |
||||
|
- **Adding instructions for edge cases that rarely apply**: Keep rules focused on patterns you use frequently. |
||||
|
- **Duplicating what's already in your codebase**: Point to canonical examples instead of copying code. |
||||
|
- **Including non-ABP patterns**: Don't add generic .NET/ASP.NET Core guidance—focus on ABP-specific conventions. |
||||
|
|
||||
|
## Contributing |
||||
|
|
||||
|
We welcome community contributions to improve these rules! You can open a PR to add new rules or improve existing ones. |
||||
|
|
||||
|
Please review our [Contribution Guide](../CONTRIBUTING.md) and [Code of Conduct](../CODE_OF_CONDUCT.md) before contributing. |
||||
|
|
||||
|
### Contribution Guidelines |
||||
|
|
||||
|
- Each rule should focus on a single ABP concept or pattern |
||||
|
- Use clear, actionable language |
||||
|
- Include examples where helpful |
||||
|
- Test your rules by using them in a real ABP project |
||||
|
- Keep ABP-specific focus—don't add general .NET patterns |
||||
|
|
||||
|
## Related Resources |
||||
|
|
||||
|
- [Cursor Rules Documentation](https://cursor.com/docs/context/rules) |
||||
|
- [ABP Framework Documentation](https://abp.io/docs) |
||||
@ -0,0 +1,182 @@ |
|||||
|
--- |
||||
|
description: "Core ABP Framework conventions - module system, dependency injection, and base classes" |
||||
|
alwaysApply: true |
||||
|
--- |
||||
|
|
||||
|
# ABP Core Conventions |
||||
|
|
||||
|
> **Documentation**: https://abp.io/docs/latest |
||||
|
> **API Reference**: https://abp.io/docs/api/ |
||||
|
|
||||
|
## 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 | |
||||
@ -0,0 +1,232 @@ |
|||||
|
--- |
||||
|
description: "ABP Application Services, DTOs, validation, and error handling patterns" |
||||
|
globs: "**/*.Application/**/*.cs,**/Application/**/*.cs,**/*AppService*.cs,**/*Dto*.cs" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# ABP Application Layer Patterns |
||||
|
|
||||
|
> **Docs**: https://abp.io/docs/latest/framework/architecture/domain-driven-design/application-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. |
||||
@ -0,0 +1,183 @@ |
|||||
|
--- |
||||
|
description: "ABP permission system and authorization patterns" |
||||
|
globs: "**/*Permission*.cs,**/*AppService*.cs,**/*Controller*.cs" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# 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 |
||||
@ -0,0 +1,90 @@ |
|||||
|
--- |
||||
|
description: "ABP CLI commands: generate-proxy, install-libs, add-package-ref, new-module, install-module, update, clean, suite generate (CRUD pages)" |
||||
|
globs: "**/*.csproj,**/appsettings*.json" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# 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]` | |
||||
@ -0,0 +1,241 @@ |
|||||
|
--- |
||||
|
description: "ABP DDD patterns - Entities, Aggregate Roots, Repositories, Domain Services" |
||||
|
globs: "**/*.Domain/**/*.cs,**/Domain/**/*.cs,**/Entities/**/*.cs" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# ABP DDD Patterns |
||||
|
|
||||
|
> **Docs**: https://abp.io/docs/latest/framework/architecture/domain-driven-design |
||||
|
|
||||
|
## 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()); |
||||
|
``` |
||||
@ -0,0 +1,151 @@ |
|||||
|
--- |
||||
|
description: "ABP layer dependency rules and project structure guardrails" |
||||
|
globs: "**/*.csproj,**/*Module*.cs" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# 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 | |
||||
@ -0,0 +1,291 @@ |
|||||
|
--- |
||||
|
description: "ABP development workflow - adding features, entities, and migrations" |
||||
|
globs: "**/*AppService*.cs,**/*Application*/**/*.cs,**/*Application.Contracts*/**/*.cs,**/*Dto*.cs,**/*DbContext*.cs,**/*.EntityFrameworkCore/**/*.cs,**/*.MongoDB/**/*.cs,**/*Permission*.cs" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# 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 |
||||
|
```bash |
||||
|
cd src/MyProject.EntityFrameworkCore |
||||
|
|
||||
|
# Add migration |
||||
|
dotnet ef migrations add Added_Book |
||||
|
|
||||
|
# Apply migration (choose one): |
||||
|
dotnet run --project ../MyProject.DbMigrator # Recommended - also seeds data |
||||
|
# OR |
||||
|
dotnet ef database update # EF Core command only |
||||
|
``` |
||||
|
|
||||
|
### 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"); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Quick Reference Commands |
||||
|
|
||||
|
### Build Solution |
||||
|
```bash |
||||
|
dotnet build |
||||
|
``` |
||||
|
|
||||
|
### Run Migrations |
||||
|
```bash |
||||
|
cd src/MyProject.EntityFrameworkCore |
||||
|
dotnet ef migrations add MigrationName |
||||
|
dotnet run --project ../MyProject.DbMigrator # Apply migration + seed data |
||||
|
``` |
||||
|
|
||||
|
### Generate Angular Proxies |
||||
|
```bash |
||||
|
abp generate-proxy -t ng |
||||
|
``` |
||||
|
|
||||
|
## 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 |
||||
@ -0,0 +1,244 @@ |
|||||
|
--- |
||||
|
description: "ABP infrastructure services - Settings, Features, Caching, Events, Background Jobs" |
||||
|
globs: "**/*Setting*.cs,**/*Feature*.cs,**/*Cache*.cs,**/*Event*.cs,**/*Job*.cs" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# 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 |
||||
@ -0,0 +1,162 @@ |
|||||
|
--- |
||||
|
description: "ABP Multi-Tenancy patterns - tenant-aware entities, data isolation, and tenant switching" |
||||
|
globs: "**/*Tenant*.cs,**/*MultiTenant*.cs,**/Entities/**/*.cs" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# 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"); |
||||
|
}); |
||||
|
``` |
||||
@ -0,0 +1,254 @@ |
|||||
|
--- |
||||
|
description: "ABP Entity Framework Core patterns - DbContext, migrations, repositories" |
||||
|
globs: "**/*.EntityFrameworkCore/**/*.cs,**/EntityFrameworkCore/**/*.cs,**/*DbContext*.cs" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# ABP Entity Framework Core |
||||
|
|
||||
|
> **Docs**: https://abp.io/docs/latest/framework/data/entity-framework-core |
||||
|
|
||||
|
## 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 |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
@ -0,0 +1,203 @@ |
|||||
|
--- |
||||
|
description: "ABP MongoDB patterns - MongoDbContext and repositories" |
||||
|
globs: "**/*.MongoDB/**/*.cs,**/MongoDB/**/*.cs,**/*MongoDb*.cs" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# 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 |
||||
@ -0,0 +1,79 @@ |
|||||
|
--- |
||||
|
description: "ABP Single-Layer (No-Layers) application template specific patterns" |
||||
|
globs: "**/src/*/*Module.cs,**/src/*/Entities/**/*.cs,**/src/*/Services/**/*.cs,**/src/*/Data/**/*.cs" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# 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 |
||||
|
} |
||||
|
``` |
||||
@ -0,0 +1,209 @@ |
|||||
|
--- |
||||
|
description: "ABP Microservice solution template specific patterns" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# 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 |
||||
@ -0,0 +1,234 @@ |
|||||
|
--- |
||||
|
description: "ABP Module solution template specific patterns" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# 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 |
||||
@ -0,0 +1,270 @@ |
|||||
|
--- |
||||
|
description: "ABP testing patterns - unit tests and integration tests" |
||||
|
globs: "test/**/*.cs,tests/**/*.cs,**/*Tests*/**/*.cs,**/*Test*.cs" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# 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 |
||||
@ -0,0 +1,221 @@ |
|||||
|
--- |
||||
|
description: "ABP Angular UI patterns and best practices" |
||||
|
globs: "**/angular/**/*.ts,**/angular/**/*.html,**/*.component.ts" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# 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` |
||||
@ -0,0 +1,207 @@ |
|||||
|
--- |
||||
|
description: "ABP Blazor UI patterns and components" |
||||
|
globs: "**/*.razor,**/Blazor/**/*.cs,**/*.Blazor*/**/*.cs" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# 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; } |
||||
|
} |
||||
|
``` |
||||
@ -0,0 +1,258 @@ |
|||||
|
--- |
||||
|
description: "ABP MVC and Razor Pages UI patterns" |
||||
|
globs: "**/*.cshtml,**/Pages/**/*.cs,**/Views/**/*.cs,**/Controllers/**/*.cs" |
||||
|
alwaysApply: false |
||||
|
--- |
||||
|
|
||||
|
# 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") |
||||
|
); |
||||
|
}); |
||||
|
``` |
||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 269 KiB |
@ -0,0 +1,153 @@ |
|||||
|
# Which Open-Source PDF Libraries Are Recently Popular ? A Data-Driven Look At PDF Topic |
||||
|
|
||||
|
So you're looking for a PDF library in .NET, right? Here's the thing - just because something has a million downloads doesn't mean it's what you should use *today*. I'm looking at **recent download momentum** (how many people are actually using it NOW via NuGet) and **GitHub activity** (are they still maintaining this thing or did they abandon it?). |
||||
|
|
||||
|
I pulled data from the last ~90 days for the main players in the .NET PDF space. Here's what's actually happening: |
||||
|
|
||||
|
## Popularity Comparison of .NET PDF Libraries (*ordered by score*) |
||||
|
|
||||
|
| Library | GitHub Stars | Avg Daily NuGet Downloads | Total NuGet Downloads | **Popularity Score** | |
||||
|
|---------|---------------|-----------------------------|----------------------------|---------------------| |
||||
|
| **[Microsoft.Playwright](https://github.com/microsoft/playwright-dotnet)** | [2.9k](https://github.com/microsoft/playwright-dotnet) | [23k](https://www.nuget.org/packages/Microsoft.Playwright) | 39M | **71/100** | |
||||
|
| **[QuestPDF](https://github.com/QuestPDF/QuestPDF)** | [13.7k](https://github.com/QuestPDF/QuestPDF) | [8.2k](https://www.nuget.org/packages/QuestPDF) | 15M | **54/100** | |
||||
|
| **[PDFsharp](https://github.com/empira/PDFsharp)** | [862](https://github.com/empira/PDFsharp) | [9k](https://www.nuget.org/packages/PdfSharp) | 47M | **48/100** | |
||||
|
| **[iText](https://github.com/itext/itext-dotnet)** | [1.9k](https://github.com/itext/itext-dotnet) | [17.2k](https://www.nuget.org/packages/itext) | 16M | **44/100** | |
||||
|
| **[PuppeteerSharp](https://github.com/hardkoded/puppeteer-sharp)** | [3.8k](https://github.com/hardkoded/puppeteer-sharp) | [8.7k](https://www.nuget.org/packages/PuppeteerSharp) | 26M | **40/100** | |
||||
|
|
||||
|
**How I calculated the score:** I weighted GitHub Stars (30%), Daily Downloads (40% - because that's what matters NOW), and Total Downloads (30% - for historical context). Everything normalized to 0-100 before weighting. Higher = better momentum overall. |
||||
|
|
||||
|
## The Breakdown - What You Actually Need to Know |
||||
|
|
||||
|
### [PDFsharp](https://docs.pdfsharp.net/) |
||||
|
|
||||
|
 |
||||
|
|
||||
|
**NuGet:** [PdfSharp](https://www.nuget.org/packages/PdfSharp) | **GitHub:** [empira/PDFsharp](https://github.com/empira/PDFsharp) |
||||
|
|
||||
|
**What it does:** Code-first PDF stuff - drawing, manipulating, merging, that kind of thing. Not for HTML/browser rendering though, so don't try to convert your React app to PDF with this. |
||||
|
|
||||
|
**What's the vibe?** **Stable, but kinda old school.** It's got the biggest total download count (47M!) but only pulling ~9k/day now. They updated it 2 weeks ago (Jan 6) so it's alive, and it supports .NET 8-10 which is nice. The GitHub stars (862) are pretty low compared to the shiny new kids, but honestly? It's been around forever and people still use it. It's the reliable old workhorse. |
||||
|
|
||||
|
**Pick this if:** |
||||
|
- You need to build PDFs from scratch with code (not HTML) |
||||
|
- You want to draw graphics, manipulate existing PDFs, merge files |
||||
|
- You don't want browser engines anywhere near your project |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### [iText](https://itextpdf.com/) |
||||
|
|
||||
|
 |
||||
|
|
||||
|
**NuGet:** [itext](https://www.nuget.org/packages/itext/) | **GitHub:** [itext/itext-dotnet](https://github.com/itext/itext-dotnet) |
||||
|
|
||||
|
**What it does:** The enterprise beast. Digital signatures, PDF compliance (PDF/A, PDF/UA), forms, all that fancy stuff. Can do HTML-to-PDF too if you need it. |
||||
|
|
||||
|
**What's the vibe?** **Actually doing pretty well!** ~17.2k downloads/day (highest for code-first libs), updated literally yesterday (Jan 18). They're moving fast. 1.9k stars isn't huge but the community seems active. The catch? This is the enterprise option - check the licensing before you commit if you're doing commercial work. |
||||
|
|
||||
|
**Pick this if:** |
||||
|
- You need digital signatures, PDF compliance, or advanced form stuff |
||||
|
- Your company is cool with licensing fees (or you're doing open source) |
||||
|
- You need serious PDF manipulation features |
||||
|
- You want HTML-to-PDF AND code-based generation in one package |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### [Microsoft.Playwright](https://playwright.dev/dotnet/) |
||||
|
|
||||
|
 |
||||
|
|
||||
|
**NuGet:** [Microsoft.Playwright](https://www.nuget.org/packages/Microsoft.Playwright) | **GitHub:** [microsoft/playwright-dotnet](https://github.com/microsoft/playwright-dotnet) |
||||
|
|
||||
|
**What it does:** Browser automation that can turn HTML/CSS/JS into PDFs. Uses real browser engines (Chromium, WebKit, Firefox) so your PDFs look exactly like they would in a browser. |
||||
|
|
||||
|
**What's the vibe?** **Killing it.** ~23k downloads/day (highest in this whole list!). It's Microsoft-backed so you know they're not gonna abandon it anytime soon. Last commit was December 3rd but honestly that's fine, they're actively maintaining. 2.9k stars and climbing. If you need to turn web pages into PDFs, this is probably your best bet right now. |
||||
|
|
||||
|
**Pick this if:** |
||||
|
- You need to convert HTML/CSS/JS to PDF and want it to look EXACTLY like the browser |
||||
|
- You're working with SPAs, dynamic content, or web templates |
||||
|
- You also need browser automation/testing (bonus!) |
||||
|
- Layout accuracy is critical (forms, dashboards, etc.) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### [PuppeteerSharp](https://www.puppeteersharp.com/) |
||||
|
|
||||
|
 |
||||
|
|
||||
|
**NuGet:** [PuppeteerSharp](https://www.nuget.org/packages/PuppeteerSharp) | **GitHub:** [hardkoded/puppeteer-sharp](https://github.com/hardkoded/puppeteer-sharp) |
||||
|
|
||||
|
**What it does:** Basically Playwright's older sibling. Uses headless Chromium to turn HTML into PDFs. Same idea, different API. |
||||
|
|
||||
|
**What's the vibe?** **Stable but losing ground.** Got updated last week (Jan 12) so it's maintained, but ~8.7k/day is way less than Playwright's ~23k. 3.8k stars is decent though. It works fine, but Playwright is eating its lunch. Still, if you know Puppeteer already or only need Chromium, this might be fine. |
||||
|
|
||||
|
**Pick this if:** |
||||
|
- You already know Puppeteer from Node.js and want the same vibe in .NET |
||||
|
- You only need Chromium (don't care about Firefox/WebKit) |
||||
|
- You have existing Puppeteer code you're porting |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
|
||||
|
|
||||
|
### [QuestPDF](https://github.com/QuestPDF/QuestPDF) |
||||
|
|
||||
|
 |
||||
|
|
||||
|
**NuGet:** [QuestPDF](https://www.nuget.org/packages/QuestPDF) | **GitHub:** [QuestPDF/QuestPDF](https://github.com/QuestPDF/QuestPDF) |
||||
|
|
||||
|
**What it does:** Build PDFs with fluent C# APIs. Think of it like building a UI layout, but for PDFs. No HTML needed - it's all code, all .NET. |
||||
|
|
||||
|
**What's the vibe?** **The community favorite.** 13.7k stars (most by far!), updated yesterday (Jan 18). ~8.2k downloads/day isn't the highest but the community is clearly excited about it. Modern API, active dev, people seem to actually enjoy using it. If you're building reports/invoices from code and want something that feels modern, this is it. |
||||
|
|
||||
|
**Pick this if:** |
||||
|
- You want to build PDFs with code (not HTML) and you like fluent APIs |
||||
|
- You're generating reports, invoices, structured documents |
||||
|
- You want zero browser dependencies |
||||
|
- You care about type safety and maintainable code |
||||
|
- You want something that feels modern and well-designed |
||||
|
|
||||
|
|
||||
|
|
||||
|
## Who's Winning Right Now? |
||||
|
|
||||
|
Here's what the numbers are telling us: |
||||
|
|
||||
|
### Code-First Libraries (Building PDFs with Code) |
||||
|
|
||||
|
**[QuestPDF](https://github.com/QuestPDF/QuestPDF)** - Score: 54/100 |
||||
|
The people's choice. Most GitHub stars (13.7k), updated yesterday, community loves it. Downloads aren't the highest but the engagement is real. This is what people are excited about. |
||||
|
|
||||
|
**[iText](https://github.com/itext/itext-dotnet)** - Score: 44/100 |
||||
|
Actually pulling the most daily downloads (~17.2k/day) for code-first libs, also updated yesterday. The enterprise crowd is still using this heavily. Just watch that licensing. |
||||
|
|
||||
|
**[PDFsharp](https://github.com/empira/PDFsharp)** - Score: 48/100 |
||||
|
The old reliable. 47M total downloads but only ~9k/day now. It works, it's stable, but it's not where the momentum is. Still a solid choice if you need something battle-tested. |
||||
|
|
||||
|
### HTML/Browser-Based Libraries (Turning Web Pages into PDFs) |
||||
|
|
||||
|
**[Microsoft.Playwright](https://github.com/microsoft/playwright-dotnet)** - Score: 71/100 |
||||
|
Winner winner. ~23k downloads/day (highest overall), Microsoft backing, actively maintained. If you need HTML-to-PDF, this is probably the move. |
||||
|
|
||||
|
**[PuppeteerSharp](https://github.com/hardkoded/puppeteer-sharp)** - Score: 40/100 |
||||
|
Still kicking around at ~8.7k/day but Playwright is clearly the future. Updated last week so it's not dead, just... less popular. |
||||
|
|
||||
|
|
||||
|
|
||||
|
## TL;DR - What Should You Actually Use? |
||||
|
|
||||
|
**Building PDFs from code (not HTML):** |
||||
|
- **QuestPDF** - If you want something modern and the community is raving about it (13.7k stars!) |
||||
|
- **iText** - If you need enterprise features and can handle the licensing |
||||
|
- **PDFsharp** - If you want the battle-tested option that's been around forever |
||||
|
|
||||
|
**Converting HTML/web pages to PDF:** |
||||
|
- **Playwright** - Just use this. It's winning right now (~23k/day), Microsoft-backed, actively maintained. Game over. |
||||
|
- **PuppeteerSharp** - Only if you really need Chromium-only or you're migrating from Node.js Puppeteer |
||||
|
|
||||
|
**Bottom line:** For HTML-to-PDF, Playwright is dominating. For code-first, QuestPDF has the hype but iText has the downloads. Choose your fighter. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
*Numbers from GitHub and NuGet as of January 19, 2026. Daily downloads are from the last 90 days.* |
||||
|
|
||||
|
After Width: | Height: | Size: 494 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 22 KiB |
@ -0,0 +1,189 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Learn how to add expandable row details to data tables using the Extensible Table Row Detail component in ABP Framework Angular UI." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
# Extensible Table Row Detail for Angular UI |
||||
|
|
||||
|
## Introduction |
||||
|
|
||||
|
The `<abp-extensible-table-row-detail>` component allows you to add expandable row details to any `<abp-extensible-table>`. When users click the expand icon, additional content is revealed below the row. |
||||
|
|
||||
|
<img alt="Extensible Table Row Detail Example" src="./images/row-detail-image.png" width="800px" style="max-width:100%"> |
||||
|
|
||||
|
## Quick Start |
||||
|
|
||||
|
### Step 1. Import the Component |
||||
|
|
||||
|
Import `ExtensibleTableRowDetailComponent` in your component: |
||||
|
|
||||
|
```typescript |
||||
|
import { |
||||
|
ExtensibleTableComponent, |
||||
|
ExtensibleTableRowDetailComponent |
||||
|
} from '@abp/ng.components/extensible'; |
||||
|
|
||||
|
@Component({ |
||||
|
// ... |
||||
|
imports: [ |
||||
|
ExtensibleTableComponent, |
||||
|
ExtensibleTableRowDetailComponent, |
||||
|
], |
||||
|
}) |
||||
|
export class MyComponent { } |
||||
|
``` |
||||
|
|
||||
|
### Step 2. Add Row Detail Template |
||||
|
|
||||
|
Place `<abp-extensible-table-row-detail>` inside `<abp-extensible-table>` with an `ng-template`: |
||||
|
|
||||
|
```html |
||||
|
<abp-extensible-table [data]="data.items" [recordsTotal]="data.totalCount" [list]="list"> |
||||
|
<abp-extensible-table-row-detail> |
||||
|
<ng-template let-row="row" let-expanded="expanded"> |
||||
|
<div class="p-3"> |
||||
|
<h5>{%{{{ row.name }}}%}</h5> |
||||
|
<p>ID: {%{{{ row.id }}}%}</p> |
||||
|
<p>Status: {%{{{ row.isActive ? 'Active' : 'Inactive' }}}%}</p> |
||||
|
</div> |
||||
|
</ng-template> |
||||
|
</abp-extensible-table-row-detail> |
||||
|
</abp-extensible-table> |
||||
|
``` |
||||
|
|
||||
|
An expand/collapse chevron icon will automatically appear in the first column of each row. |
||||
|
|
||||
|
## API |
||||
|
|
||||
|
### ExtensibleTableRowDetailComponent |
||||
|
|
||||
|
| Input | Type | Default | Description | |
||||
|
|-------|------|---------|-------------| |
||||
|
| `rowHeight` | `string` | `number` | `'100%'` | Height of the expanded row detail area | |
||||
|
|
||||
|
### Template Context Variables |
||||
|
|
||||
|
| Variable | Type | Description | |
||||
|
|----------|------|-------------| |
||||
|
| `row` | `R` | The current row data object | |
||||
|
| `expanded` | `boolean` | Whether the row is currently expanded | |
||||
|
|
||||
|
## Usage Examples |
||||
|
|
||||
|
### Basic Example |
||||
|
|
||||
|
Display additional information when a row is expanded: |
||||
|
|
||||
|
```html |
||||
|
<abp-extensible-table [data]="data.items" [list]="list"> |
||||
|
<abp-extensible-table-row-detail> |
||||
|
<ng-template let-row="row"> |
||||
|
<div class="p-3 border rounded m-2"> |
||||
|
<strong>Details for: {%{{{ row.name }}}%}</strong> |
||||
|
<pre>{%{{{ row | json }}}%}</pre> |
||||
|
</div> |
||||
|
</ng-template> |
||||
|
</abp-extensible-table-row-detail> |
||||
|
</abp-extensible-table> |
||||
|
``` |
||||
|
|
||||
|
### With Custom Row Height |
||||
|
|
||||
|
Specify a fixed height for the detail area: |
||||
|
|
||||
|
```html |
||||
|
<abp-extensible-table-row-detail [rowHeight]="200"> |
||||
|
<ng-template let-row="row"> |
||||
|
<div class="p-3">Fixed 200px height content</div> |
||||
|
</ng-template> |
||||
|
</abp-extensible-table-row-detail> |
||||
|
``` |
||||
|
|
||||
|
### Using Expanded State |
||||
|
|
||||
|
Apply conditional styling based on expansion state: |
||||
|
|
||||
|
```html |
||||
|
<abp-extensible-table-row-detail> |
||||
|
<ng-template let-row="row" let-expanded="expanded"> |
||||
|
<div class="p-3" [class.fade-in]="expanded"> |
||||
|
<p>This row is {%{{{ expanded ? 'expanded' : 'collapsed' }}}%}</p> |
||||
|
</div> |
||||
|
</ng-template> |
||||
|
</abp-extensible-table-row-detail> |
||||
|
``` |
||||
|
|
||||
|
### With Badges and Localization |
||||
|
|
||||
|
```html |
||||
|
<abp-extensible-table-row-detail> |
||||
|
<ng-template let-row="row"> |
||||
|
<div class="p-3 bg-light border rounded m-2"> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-6"> |
||||
|
<p class="mb-1"><strong>{%{{{ 'MyModule::Name' | abpLocalization }}}%}</strong></p> |
||||
|
<p class="text-muted">{%{{{ row.name }}}%}</p> |
||||
|
</div> |
||||
|
<div class="col-md-6"> |
||||
|
<p class="mb-1"><strong>{%{{{ 'MyModule::Status' | abpLocalization }}}%}</strong></p> |
||||
|
<p> |
||||
|
@if (row.isActive) { |
||||
|
<span class="badge bg-success">{%{{{ 'AbpUi::Yes' | abpLocalization }}}%}</span> |
||||
|
} @else { |
||||
|
<span class="badge bg-secondary">{%{{{ 'AbpUi::No' | abpLocalization }}}%}</span> |
||||
|
} |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</ng-template> |
||||
|
</abp-extensible-table-row-detail> |
||||
|
``` |
||||
|
|
||||
|
## Alternative: Direct Template Input |
||||
|
|
||||
|
For simpler use cases, you can use the `rowDetailTemplate` input on `<abp-extensible-table>` directly: |
||||
|
|
||||
|
```html |
||||
|
<abp-extensible-table |
||||
|
[data]="data.items" |
||||
|
[list]="list" |
||||
|
[rowDetailTemplate]="detailTemplate" |
||||
|
/> |
||||
|
|
||||
|
<ng-template #detailTemplate let-row="row"> |
||||
|
<div class="p-3">{%{{{ row.name }}}%}</div> |
||||
|
</ng-template> |
||||
|
``` |
||||
|
|
||||
|
## Events |
||||
|
|
||||
|
### rowDetailToggle |
||||
|
|
||||
|
The `rowDetailToggle` output emits when a row is expanded or collapsed: |
||||
|
|
||||
|
```html |
||||
|
<abp-extensible-table |
||||
|
[data]="data.items" |
||||
|
[list]="list" |
||||
|
(rowDetailToggle)="onRowToggle($event)" |
||||
|
> |
||||
|
<abp-extensible-table-row-detail> |
||||
|
<ng-template let-row="row">...</ng-template> |
||||
|
</abp-extensible-table-row-detail> |
||||
|
</abp-extensible-table> |
||||
|
``` |
||||
|
|
||||
|
```typescript |
||||
|
onRowToggle(row: MyDto) { |
||||
|
console.log('Row toggled:', row); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## See Also |
||||
|
|
||||
|
- [Data Table Column Extensions](data-table-column-extensions.md) |
||||
|
- [Entity Action Extensions](entity-action-extensions.md) |
||||
|
- [Extensions Overview](extensions-overall.md) |
||||
|
After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 152 KiB |
@ -0,0 +1,55 @@ |
|||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using Volo.Abp.MultiTenancy; |
||||
|
using Volo.Abp.Security.Claims; |
||||
|
|
||||
|
namespace Volo.Abp.Authorization.Permissions.Resources; |
||||
|
|
||||
|
public class ClientResourcePermissionValueProvider : ResourcePermissionValueProvider |
||||
|
{ |
||||
|
public const string ProviderName = "C"; |
||||
|
|
||||
|
public override string Name => ProviderName; |
||||
|
|
||||
|
protected ICurrentTenant CurrentTenant { get; } |
||||
|
|
||||
|
public ClientResourcePermissionValueProvider(IResourcePermissionStore resourcePermissionStore, ICurrentTenant currentTenant) |
||||
|
: base(resourcePermissionStore) |
||||
|
{ |
||||
|
CurrentTenant = currentTenant; |
||||
|
} |
||||
|
|
||||
|
public override async Task<PermissionGrantResult> CheckAsync(ResourcePermissionValueCheckContext context) |
||||
|
{ |
||||
|
var clientId = context.Principal?.FindFirst(AbpClaimTypes.ClientId)?.Value; |
||||
|
|
||||
|
if (clientId == null) |
||||
|
{ |
||||
|
return PermissionGrantResult.Undefined; |
||||
|
} |
||||
|
|
||||
|
using (CurrentTenant.Change(null)) |
||||
|
{ |
||||
|
return await ResourcePermissionStore.IsGrantedAsync(context.Permission.Name, context.ResourceName, context.ResourceKey, Name, clientId) |
||||
|
? PermissionGrantResult.Granted |
||||
|
: PermissionGrantResult.Undefined; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public override async Task<MultiplePermissionGrantResult> CheckAsync(ResourcePermissionValuesCheckContext context) |
||||
|
{ |
||||
|
var permissionNames = context.Permissions.Select(x => x.Name).Distinct().ToArray(); |
||||
|
Check.NotNullOrEmpty(permissionNames, nameof(permissionNames)); |
||||
|
|
||||
|
var clientId = context.Principal?.FindFirst(AbpClaimTypes.ClientId)?.Value; |
||||
|
if (clientId == null) |
||||
|
{ |
||||
|
return new MultiplePermissionGrantResult(permissionNames); |
||||
|
} |
||||
|
|
||||
|
using (CurrentTenant.Change(null)) |
||||
|
{ |
||||
|
return await ResourcePermissionStore.IsGrantedAsync(permissionNames, context.ResourceName, context.ResourceKey, Name, clientId); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,10 @@ |
|||||
|
using System; |
||||
|
|
||||
|
namespace Volo.Abp.BackgroundWorkers.Hangfire; |
||||
|
|
||||
|
public class AbpHangfirePeriodicBackgroundWorkerAdapterOptions |
||||
|
{ |
||||
|
public TimeZoneInfo TimeZone { get; set; } = TimeZoneInfo.Utc; |
||||
|
|
||||
|
public string Queue { get; set; } = default!; |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
using Volo.Abp.Cli.Commands; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Args; |
||||
|
|
||||
|
public static class CommandLineArgsExtensions |
||||
|
{ |
||||
|
public static bool IsMcpCommand(this CommandLineArgs args) |
||||
|
{ |
||||
|
return args.IsCommand(McpCommand.Name); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,197 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Diagnostics; |
||||
|
using System.IO; |
||||
|
using System.Reflection; |
||||
|
using System.Text; |
||||
|
using System.Text.Json; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Microsoft.Extensions.Logging.Abstractions; |
||||
|
using Volo.Abp.Cli.Args; |
||||
|
using Volo.Abp.Cli.Auth; |
||||
|
using Volo.Abp.Cli.Commands.Models; |
||||
|
using Volo.Abp.Cli.Commands.Services; |
||||
|
using Volo.Abp.Cli.Licensing; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Volo.Abp.Internal.Telemetry; |
||||
|
using Volo.Abp.Internal.Telemetry.Constants; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands; |
||||
|
|
||||
|
public class McpCommand : IConsoleCommand, ITransientDependency |
||||
|
{ |
||||
|
private const string LogSource = nameof(McpCommand); |
||||
|
public const string Name = "mcp"; |
||||
|
|
||||
|
private readonly AuthService _authService; |
||||
|
private readonly IApiKeyService _apiKeyService; |
||||
|
private readonly McpServerService _mcpServerService; |
||||
|
private readonly McpHttpClientService _mcpHttpClient; |
||||
|
private readonly IMcpLogger _mcpLogger; |
||||
|
private readonly ITelemetryService _telemetryService; |
||||
|
|
||||
|
public ILogger<McpCommand> Logger { get; set; } |
||||
|
|
||||
|
public McpCommand( |
||||
|
IApiKeyService apiKeyService, |
||||
|
AuthService authService, |
||||
|
McpServerService mcpServerService, |
||||
|
McpHttpClientService mcpHttpClient, |
||||
|
IMcpLogger mcpLogger, |
||||
|
ITelemetryService telemetryService) |
||||
|
{ |
||||
|
_apiKeyService = apiKeyService; |
||||
|
_authService = authService; |
||||
|
_mcpServerService = mcpServerService; |
||||
|
_mcpHttpClient = mcpHttpClient; |
||||
|
_mcpLogger = mcpLogger; |
||||
|
_telemetryService = telemetryService; |
||||
|
Logger = NullLogger<McpCommand>.Instance; |
||||
|
} |
||||
|
|
||||
|
public async Task ExecuteAsync(CommandLineArgs commandLineArgs) |
||||
|
{ |
||||
|
await ValidateLicenseAsync(); |
||||
|
|
||||
|
var option = commandLineArgs.Target; |
||||
|
|
||||
|
if (!string.IsNullOrEmpty(option) && option.Equals("get-config", StringComparison.OrdinalIgnoreCase)) |
||||
|
{ |
||||
|
await PrintConfigurationAsync(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
await using var _ = _telemetryService.TrackActivityAsync(ActivityNameConsts.AbpCliCommandsMcp); |
||||
|
|
||||
|
// Check server health before starting - fail if not reachable
|
||||
|
_mcpLogger.Info(LogSource, "Checking ABP.IO MCP Server connection..."); |
||||
|
var isHealthy = await _mcpHttpClient.CheckServerHealthAsync(); |
||||
|
|
||||
|
if (!isHealthy) |
||||
|
{ |
||||
|
throw new CliUsageException( |
||||
|
"Could not connect to ABP.IO MCP Server. " + |
||||
|
"The MCP server requires a connection to fetch tool definitions. " + |
||||
|
"Please check your internet connection and try again."); |
||||
|
} |
||||
|
|
||||
|
_mcpLogger.Info(LogSource, "Starting ABP MCP Server..."); |
||||
|
|
||||
|
var cts = new CancellationTokenSource(); |
||||
|
|
||||
|
ConsoleCancelEventHandler cancelHandler = (sender, e) => |
||||
|
{ |
||||
|
e.Cancel = true; |
||||
|
_mcpLogger.Info(LogSource, "Shutting down ABP MCP Server..."); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
cts.Cancel(); |
||||
|
} |
||||
|
catch (ObjectDisposedException) |
||||
|
{ |
||||
|
// CTS already disposed
|
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
Console.CancelKeyPress += cancelHandler; |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
await _mcpServerService.RunAsync(cts.Token); |
||||
|
} |
||||
|
catch (OperationCanceledException) |
||||
|
{ |
||||
|
// Expected when Ctrl+C is pressed
|
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, "Error running MCP server", ex); |
||||
|
throw; |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
Console.CancelKeyPress -= cancelHandler; |
||||
|
cts.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task ValidateLicenseAsync() |
||||
|
{ |
||||
|
var loginInfo = await _authService.GetLoginInfoAsync(); |
||||
|
|
||||
|
if (string.IsNullOrEmpty(loginInfo?.Organization)) |
||||
|
{ |
||||
|
throw new CliUsageException("Please log in with your account!"); |
||||
|
} |
||||
|
|
||||
|
var licenseResult = await _apiKeyService.GetApiKeyOrNullAsync(); |
||||
|
|
||||
|
if (licenseResult == null || !licenseResult.HasActiveLicense) |
||||
|
{ |
||||
|
var errorMessage = licenseResult?.ErrorMessage ?? "No active license found."; |
||||
|
throw new CliUsageException(errorMessage); |
||||
|
} |
||||
|
|
||||
|
if (licenseResult.LicenseEndTime.HasValue && licenseResult.LicenseEndTime.Value < DateTime.UtcNow) |
||||
|
{ |
||||
|
throw new CliUsageException("Your license has expired. Please renew your license to use the MCP server."); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private Task PrintConfigurationAsync() |
||||
|
{ |
||||
|
var config = new McpClientConfiguration |
||||
|
{ |
||||
|
McpServers = new Dictionary<string, McpServerConfig> |
||||
|
{ |
||||
|
["abp"] = new McpServerConfig |
||||
|
{ |
||||
|
Command = "abp", |
||||
|
Args = new List<string> { "mcp" }, |
||||
|
Env = new Dictionary<string, string>() |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
var json = JsonSerializer.Serialize(config, new JsonSerializerOptions |
||||
|
{ |
||||
|
WriteIndented = true, |
||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase |
||||
|
}); |
||||
|
|
||||
|
Console.WriteLine(json); |
||||
|
|
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
|
||||
|
public string GetUsageInfo() |
||||
|
{ |
||||
|
var sb = new StringBuilder(); |
||||
|
|
||||
|
sb.AppendLine(""); |
||||
|
sb.AppendLine("Usage:"); |
||||
|
sb.AppendLine(""); |
||||
|
sb.AppendLine(" abp mcp [options]"); |
||||
|
sb.AppendLine(""); |
||||
|
sb.AppendLine("Options:"); |
||||
|
sb.AppendLine(""); |
||||
|
sb.AppendLine("<no argument> (start the local MCP server)"); |
||||
|
sb.AppendLine("get-config (print MCP client configuration as JSON)"); |
||||
|
sb.AppendLine(""); |
||||
|
sb.AppendLine("Examples:"); |
||||
|
sb.AppendLine(""); |
||||
|
sb.AppendLine(" abp mcp"); |
||||
|
sb.AppendLine(" abp mcp get-config"); |
||||
|
sb.AppendLine(""); |
||||
|
|
||||
|
return sb.ToString(); |
||||
|
} |
||||
|
|
||||
|
public static string GetShortDescription() |
||||
|
{ |
||||
|
return "Runs the local MCP server and outputs client configuration for AI tool integration."; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
using System.Text.Json.Serialization; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Models; |
||||
|
|
||||
|
public class McpClientConfiguration |
||||
|
{ |
||||
|
[JsonPropertyName("mcpServers")] |
||||
|
public Dictionary<string, McpServerConfig> McpServers { get; set; } = new(); |
||||
|
} |
||||
|
|
||||
|
public class McpServerConfig |
||||
|
{ |
||||
|
[JsonPropertyName("command")] |
||||
|
public string Command { get; set; } |
||||
|
|
||||
|
[JsonPropertyName("args")] |
||||
|
public List<string> Args { get; set; } = new(); |
||||
|
|
||||
|
[JsonPropertyName("env")] |
||||
|
public Dictionary<string, string> Env { get; set; } |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,26 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
using System.Text.Json; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Models; |
||||
|
|
||||
|
public class McpToolDefinition |
||||
|
{ |
||||
|
public string Name { get; set; } |
||||
|
public string Description { get; set; } |
||||
|
public McpToolInputSchema InputSchema { get; set; } |
||||
|
public JsonElement? OutputSchema { get; set; } |
||||
|
} |
||||
|
|
||||
|
public class McpToolInputSchema |
||||
|
{ |
||||
|
public string Type { get; set; } = "object"; |
||||
|
public Dictionary<string, McpToolProperty> Properties { get; set; } |
||||
|
public List<string> Required { get; set; } |
||||
|
} |
||||
|
|
||||
|
public class McpToolProperty |
||||
|
{ |
||||
|
public string Type { get; set; } |
||||
|
public string Description { get; set; } |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,47 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Text.Json; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using ModelContextProtocol.Protocol; |
||||
|
using ModelContextProtocol.Server; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Services; |
||||
|
|
||||
|
internal class AbpMcpServerTool : McpServerTool |
||||
|
{ |
||||
|
private readonly string _name; |
||||
|
private readonly string _description; |
||||
|
private readonly JsonElement _inputSchema; |
||||
|
private readonly JsonElement? _outputSchema; |
||||
|
private readonly Func<RequestContext<CallToolRequestParams>, CancellationToken, ValueTask<CallToolResult>> _handler; |
||||
|
|
||||
|
public AbpMcpServerTool( |
||||
|
string name, |
||||
|
string description, |
||||
|
JsonElement inputSchema, |
||||
|
JsonElement? outputSchema, |
||||
|
Func<RequestContext<CallToolRequestParams>, CancellationToken, ValueTask<CallToolResult>> handler) |
||||
|
{ |
||||
|
_name = name; |
||||
|
_description = description; |
||||
|
_inputSchema = inputSchema; |
||||
|
_outputSchema = outputSchema; |
||||
|
_handler = handler; |
||||
|
} |
||||
|
|
||||
|
public override Tool ProtocolTool => new Tool |
||||
|
{ |
||||
|
Name = _name, |
||||
|
Description = _description, |
||||
|
InputSchema = _inputSchema, |
||||
|
OutputSchema = _outputSchema |
||||
|
}; |
||||
|
|
||||
|
public override IReadOnlyList<object> Metadata => Array.Empty<object>(); |
||||
|
|
||||
|
public override ValueTask<CallToolResult> InvokeAsync(RequestContext<CallToolRequestParams> context, CancellationToken cancellationToken) |
||||
|
{ |
||||
|
return _handler(context, cancellationToken); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,36 @@ |
|||||
|
using System; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Services; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Logger interface for MCP operations.
|
||||
|
/// Writes detailed logs to file and critical messages (Warning/Error) to stderr.
|
||||
|
/// Log level is controlled via ABP_MCP_LOG_LEVEL environment variable.
|
||||
|
/// </summary>
|
||||
|
public interface IMcpLogger |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Logs a debug message. Only written to file when log level is Debug.
|
||||
|
/// </summary>
|
||||
|
void Debug(string source, string message); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Logs an informational message. Written to file when log level is Debug or Info.
|
||||
|
/// </summary>
|
||||
|
void Info(string source, string message); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Logs a warning message. Written to file and stderr.
|
||||
|
/// </summary>
|
||||
|
void Warning(string source, string message); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Logs an error message. Written to file and stderr.
|
||||
|
/// </summary>
|
||||
|
void Error(string source, string message); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Logs an error message with exception details. Written to file and stderr.
|
||||
|
/// </summary>
|
||||
|
void Error(string source, string message, Exception exception); |
||||
|
} |
||||
@ -0,0 +1,280 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.IO; |
||||
|
using System.Linq; |
||||
|
using System.Net; |
||||
|
using System.Net.Http; |
||||
|
using System.Text; |
||||
|
using System.Text.Json; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Volo.Abp.Cli.Commands.Models; |
||||
|
using Volo.Abp.Cli.Http; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Volo.Abp.IO; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Services; |
||||
|
|
||||
|
public class McpHttpClientService : ISingletonDependency |
||||
|
{ |
||||
|
private static readonly JsonSerializerOptions JsonSerializerOptionsWeb = new(JsonSerializerDefaults.Web); |
||||
|
|
||||
|
private const string LogSource = nameof(McpHttpClientService); |
||||
|
|
||||
|
private readonly CliHttpClientFactory _httpClientFactory; |
||||
|
private readonly ILogger<McpHttpClientService> _logger; |
||||
|
private readonly IMcpLogger _mcpLogger; |
||||
|
private readonly Lazy<Task<string>> _cachedServerUrlLazy; |
||||
|
private List<string> _validToolNames; |
||||
|
private bool _toolDefinitionsLoaded; |
||||
|
|
||||
|
public McpHttpClientService( |
||||
|
CliHttpClientFactory httpClientFactory, |
||||
|
ILogger<McpHttpClientService> logger, |
||||
|
IMcpLogger mcpLogger) |
||||
|
{ |
||||
|
_httpClientFactory = httpClientFactory; |
||||
|
_logger = logger; |
||||
|
_mcpLogger = mcpLogger; |
||||
|
_cachedServerUrlLazy = new Lazy<Task<string>>(GetMcpServerUrlInternalAsync); |
||||
|
} |
||||
|
|
||||
|
public void InitializeToolNames(List<McpToolDefinition> tools) |
||||
|
{ |
||||
|
_validToolNames = tools.Select(t => t.Name).ToList(); |
||||
|
_toolDefinitionsLoaded = true; |
||||
|
_mcpLogger.Debug(LogSource, $"Initialized tool names from cache. Count={tools.Count}, Instance={GetHashCode()}"); |
||||
|
} |
||||
|
|
||||
|
public async Task<string> CallToolAsync(string toolName, JsonElement arguments) |
||||
|
{ |
||||
|
_mcpLogger.Debug(LogSource, $"CallToolAsync called for '{toolName}'. _toolDefinitionsLoaded={_toolDefinitionsLoaded}, Instance={GetHashCode()}"); |
||||
|
|
||||
|
if (!_toolDefinitionsLoaded) |
||||
|
{ |
||||
|
throw new CliUsageException("Tool definitions have not been loaded yet. This is an internal error."); |
||||
|
} |
||||
|
|
||||
|
// Validate toolName against whitelist to prevent malicious input
|
||||
|
if (_validToolNames != null && !_validToolNames.Contains(toolName)) |
||||
|
{ |
||||
|
_mcpLogger.Warning(LogSource, $"Attempted to call unknown tool: {toolName}"); |
||||
|
return CreateErrorResponse($"Unknown tool: {toolName}"); |
||||
|
} |
||||
|
|
||||
|
var baseUrl = await GetMcpServerUrlAsync(); |
||||
|
var url = $"{baseUrl}/tools/call"; |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
using var httpClient = _httpClientFactory.CreateClient(needsAuthentication: true); |
||||
|
|
||||
|
var jsonContent = JsonSerializer.Serialize( |
||||
|
new { name = toolName, arguments }, |
||||
|
JsonSerializerOptionsWeb); |
||||
|
|
||||
|
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); |
||||
|
|
||||
|
var response = await httpClient.PostAsync(url, content); |
||||
|
|
||||
|
if (!response.IsSuccessStatusCode) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, $"API call failed with status: {response.StatusCode}"); |
||||
|
|
||||
|
// Return sanitized error message to client
|
||||
|
var errorMessage = GetSanitizedHttpErrorMessage(response.StatusCode); |
||||
|
return CreateErrorResponse(errorMessage); |
||||
|
} |
||||
|
|
||||
|
return await response.Content.ReadAsStringAsync(); |
||||
|
} |
||||
|
catch (HttpRequestException ex) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, $"Network error calling tool '{toolName}'", ex); |
||||
|
|
||||
|
// Return sanitized error to client
|
||||
|
return CreateErrorResponse(ErrorMessages.NetworkConnectivity); |
||||
|
} |
||||
|
catch (TaskCanceledException ex) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, $"Timeout calling tool '{toolName}'", ex); |
||||
|
|
||||
|
// Return sanitized error to client
|
||||
|
return CreateErrorResponse(ErrorMessages.Timeout); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, $"Unexpected error calling tool '{toolName}'", ex); |
||||
|
|
||||
|
// Return generic sanitized error to client
|
||||
|
return CreateErrorResponse(ErrorMessages.Unexpected); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task<bool> CheckServerHealthAsync() |
||||
|
{ |
||||
|
var baseUrl = await GetMcpServerUrlAsync(); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
using var httpClient = _httpClientFactory.CreateClient(needsAuthentication: false); |
||||
|
var response = await httpClient.GetAsync(baseUrl); |
||||
|
return response.IsSuccessStatusCode; |
||||
|
} |
||||
|
catch (Exception) |
||||
|
{ |
||||
|
// Silently fail health check - it's optional
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task<List<McpToolDefinition>> GetToolDefinitionsAsync() |
||||
|
{ |
||||
|
_mcpLogger.Debug(LogSource, $"GetToolDefinitionsAsync called. Instance={GetHashCode()}"); |
||||
|
|
||||
|
var baseUrl = await GetMcpServerUrlAsync(); |
||||
|
var url = $"{baseUrl}/tools"; |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
using var httpClient = _httpClientFactory.CreateClient(needsAuthentication: true); |
||||
|
var response = await httpClient.GetAsync(url); |
||||
|
|
||||
|
if (!response.IsSuccessStatusCode) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, $"Failed to fetch tool definitions with status: {response.StatusCode}"); |
||||
|
|
||||
|
// Throw sanitized exception
|
||||
|
var errorMessage = GetSanitizedHttpErrorMessage(response.StatusCode); |
||||
|
throw new CliUsageException($"Failed to fetch tool definitions: {errorMessage}"); |
||||
|
} |
||||
|
|
||||
|
var responseContent = await response.Content.ReadAsStringAsync(); |
||||
|
|
||||
|
// The API returns { tools: [...] } format
|
||||
|
var result = JsonSerializer.Deserialize<McpToolsResponse>(responseContent, JsonSerializerOptionsWeb); |
||||
|
var tools = result?.Tools ?? new List<McpToolDefinition>(); |
||||
|
|
||||
|
// Cache tool names for validation
|
||||
|
_validToolNames = tools.Select(t => t.Name).ToList(); |
||||
|
_toolDefinitionsLoaded = true; |
||||
|
|
||||
|
_mcpLogger.Debug(LogSource, $"Tool definitions loaded successfully. _toolDefinitionsLoaded={_toolDefinitionsLoaded}, Tool count={tools.Count}, Instance={GetHashCode()}"); |
||||
|
|
||||
|
return tools; |
||||
|
} |
||||
|
catch (HttpRequestException ex) |
||||
|
{ |
||||
|
throw CreateHttpExceptionWithInner(ex, "Network error fetching tool definitions"); |
||||
|
} |
||||
|
catch (TaskCanceledException ex) |
||||
|
{ |
||||
|
throw CreateHttpExceptionWithInner(ex, "Timeout fetching tool definitions"); |
||||
|
} |
||||
|
catch (JsonException ex) |
||||
|
{ |
||||
|
throw CreateHttpExceptionWithInner(ex, "JSON parsing error"); |
||||
|
} |
||||
|
catch (CliUsageException) |
||||
|
{ |
||||
|
// Already sanitized, rethrow as-is
|
||||
|
throw; |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
throw CreateHttpExceptionWithInner(ex, "Unexpected error fetching tool definitions"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task<string> GetMcpServerUrlAsync() |
||||
|
{ |
||||
|
return await _cachedServerUrlLazy.Value; |
||||
|
} |
||||
|
|
||||
|
private async Task<string> GetMcpServerUrlInternalAsync() |
||||
|
{ |
||||
|
// Check config file
|
||||
|
if (File.Exists(CliPaths.McpConfig)) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var json = await FileHelper.ReadAllTextAsync(CliPaths.McpConfig); |
||||
|
var config = JsonSerializer.Deserialize<McpConfig>(json, JsonSerializerOptionsWeb); |
||||
|
if (!string.IsNullOrWhiteSpace(config?.ServerUrl)) |
||||
|
{ |
||||
|
return config.ServerUrl.TrimEnd('/'); |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger.LogWarning(ex, "Failed to read MCP config file"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Return default
|
||||
|
return CliConsts.DefaultMcpServerUrl; |
||||
|
} |
||||
|
|
||||
|
private string CreateErrorResponse(string errorMessage) |
||||
|
{ |
||||
|
return JsonSerializer.Serialize(new |
||||
|
{ |
||||
|
content = new[] |
||||
|
{ |
||||
|
new |
||||
|
{ |
||||
|
type = "text", |
||||
|
text = errorMessage |
||||
|
} |
||||
|
}, |
||||
|
isError = true |
||||
|
}, JsonSerializerOptionsWeb); |
||||
|
} |
||||
|
|
||||
|
private string GetSanitizedHttpErrorMessage(HttpStatusCode statusCode) |
||||
|
{ |
||||
|
return statusCode switch |
||||
|
{ |
||||
|
HttpStatusCode.Unauthorized => "Authentication failed. Please ensure you are logged in with a valid account.", |
||||
|
HttpStatusCode.Forbidden => "Access denied. You do not have permission to use this tool.", |
||||
|
HttpStatusCode.NotFound => "The requested tool could not be found. It may have been removed or is temporarily unavailable.", |
||||
|
HttpStatusCode.BadRequest => "The tool request was invalid. Please check your input parameters and try again.", |
||||
|
(HttpStatusCode)429 => "Rate limit exceeded. Please wait a moment before trying again.", // TooManyRequests not available in .NET Standard 2.0
|
||||
|
HttpStatusCode.ServiceUnavailable => "The service is temporarily unavailable. Please try again later.", |
||||
|
HttpStatusCode.InternalServerError => "The tool execution encountered an internal error. Please try again later.", |
||||
|
_ => "The tool execution failed. Please try again later." |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private CliUsageException CreateHttpExceptionWithInner(Exception ex, string context) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, context, ex); |
||||
|
|
||||
|
var userMessage = ex switch |
||||
|
{ |
||||
|
HttpRequestException => "Network connectivity issue. Please check your internet connection and try again.", |
||||
|
TaskCanceledException => "Request timed out. Please try again.", |
||||
|
JsonException => "Invalid response format received.", |
||||
|
_ => "An unexpected error occurred. Please try again later." |
||||
|
}; |
||||
|
|
||||
|
return new CliUsageException($"Failed to fetch tool definitions: {userMessage}", ex); |
||||
|
} |
||||
|
|
||||
|
private static class ErrorMessages |
||||
|
{ |
||||
|
public const string NetworkConnectivity = "The tool execution failed due to a network connectivity issue. Please check your internet connection and try again."; |
||||
|
public const string Timeout = "The tool execution timed out. The operation took too long to complete. Please try again."; |
||||
|
public const string Unexpected = "The tool execution failed due to an unexpected error. Please try again later."; |
||||
|
} |
||||
|
|
||||
|
private class McpConfig |
||||
|
{ |
||||
|
public string ServerUrl { get; set; } |
||||
|
} |
||||
|
|
||||
|
private class McpToolsResponse |
||||
|
{ |
||||
|
public List<McpToolDefinition> Tools { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,150 @@ |
|||||
|
using System; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Services; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// MCP logger implementation that writes to both file (via Serilog) and stderr.
|
||||
|
/// - All logs at or above the configured level are written to file via ILogger
|
||||
|
/// - Warning and Error logs are also written to stderr
|
||||
|
/// - Log level is controlled via ABP_MCP_LOG_LEVEL environment variable
|
||||
|
/// </summary>
|
||||
|
public class McpLogger : IMcpLogger, ISingletonDependency |
||||
|
{ |
||||
|
private const string LogPrefix = "[MCP]"; |
||||
|
|
||||
|
private readonly ILogger<McpLogger> _logger; |
||||
|
private readonly McpLogLevel _configuredLogLevel; |
||||
|
|
||||
|
public McpLogger(ILogger<McpLogger> logger) |
||||
|
{ |
||||
|
_logger = logger; |
||||
|
_configuredLogLevel = GetConfiguredLogLevel(); |
||||
|
} |
||||
|
|
||||
|
public void Debug(string source, string message) |
||||
|
{ |
||||
|
Log(McpLogLevel.Debug, source, message); |
||||
|
} |
||||
|
|
||||
|
public void Info(string source, string message) |
||||
|
{ |
||||
|
Log(McpLogLevel.Info, source, message); |
||||
|
} |
||||
|
|
||||
|
public void Warning(string source, string message) |
||||
|
{ |
||||
|
Log(McpLogLevel.Warning, source, message); |
||||
|
} |
||||
|
|
||||
|
public void Error(string source, string message) |
||||
|
{ |
||||
|
Log(McpLogLevel.Error, source, message); |
||||
|
} |
||||
|
|
||||
|
public void Error(string source, string message, Exception exception) |
||||
|
{ |
||||
|
#if DEBUG
|
||||
|
var fullMessage = $"{message} | Exception: {exception.GetType().Name}: {exception.Message}"; |
||||
|
#else
|
||||
|
var fullMessage = $"{message} | Exception: {exception.GetType().Name}"; |
||||
|
#endif
|
||||
|
Log(McpLogLevel.Error, source, fullMessage); |
||||
|
} |
||||
|
|
||||
|
private void Log(McpLogLevel level, string source, string message) |
||||
|
{ |
||||
|
if (_configuredLogLevel == McpLogLevel.None || level < _configuredLogLevel) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var mcpFormattedMessage = $"{LogPrefix}[{source}] {message}"; |
||||
|
|
||||
|
// File logging via Serilog
|
||||
|
switch (level) |
||||
|
{ |
||||
|
case McpLogLevel.Debug: |
||||
|
_logger.LogDebug(mcpFormattedMessage); |
||||
|
break; |
||||
|
case McpLogLevel.Info: |
||||
|
_logger.LogInformation(mcpFormattedMessage); |
||||
|
break; |
||||
|
case McpLogLevel.Warning: |
||||
|
_logger.LogWarning(mcpFormattedMessage); |
||||
|
break; |
||||
|
case McpLogLevel.Error: |
||||
|
_logger.LogError(mcpFormattedMessage); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
// Stderr output for MCP protocol (Warning/Error only)
|
||||
|
if (level >= McpLogLevel.Warning) |
||||
|
{ |
||||
|
WriteToStderr(level.ToString().ToUpperInvariant(), message); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void WriteToStderr(string level, string message) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
// Use synchronous write to avoid async issues in MCP context
|
||||
|
Console.Error.WriteLine($"{LogPrefix}[{level}] {message}"); |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
// Silently ignore stderr write errors
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static McpLogLevel GetConfiguredLogLevel() |
||||
|
{ |
||||
|
var envValue = Environment.GetEnvironmentVariable(CliConsts.McpLogLevelEnvironmentVariable); |
||||
|
var isEmpty = string.IsNullOrWhiteSpace(envValue); |
||||
|
|
||||
|
#if DEBUG
|
||||
|
// In development builds, allow full control via environment variable
|
||||
|
if (isEmpty) |
||||
|
{ |
||||
|
return McpLogLevel.Info; // Default level
|
||||
|
} |
||||
|
|
||||
|
return ParseLogLevel(envValue, allowDebug: true); |
||||
|
#else
|
||||
|
// In release builds, restrict to Warning or higher (ignore env variable for Debug/Info)
|
||||
|
if (isEmpty) |
||||
|
{ |
||||
|
return McpLogLevel.Info; // Default level
|
||||
|
} |
||||
|
|
||||
|
return ParseLogLevel(envValue, allowDebug: false); |
||||
|
#endif
|
||||
|
} |
||||
|
|
||||
|
private static McpLogLevel ParseLogLevel(string value, bool allowDebug) |
||||
|
{ |
||||
|
return value.ToLowerInvariant() switch |
||||
|
{ |
||||
|
"debug" => allowDebug ? McpLogLevel.Debug : McpLogLevel.Info, |
||||
|
"info" => McpLogLevel.Info, |
||||
|
"warning" => McpLogLevel.Warning, |
||||
|
"error" => McpLogLevel.Error, |
||||
|
"none" => McpLogLevel.None, |
||||
|
_ => McpLogLevel.Info |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Log levels for MCP logging.
|
||||
|
/// </summary>
|
||||
|
public enum McpLogLevel |
||||
|
{ |
||||
|
Debug = 0, |
||||
|
Info = 1, |
||||
|
Warning = 2, |
||||
|
Error = 3, |
||||
|
None = 4 |
||||
|
} |
||||
@ -0,0 +1,181 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Text.Json; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Microsoft.Extensions.Logging.Abstractions; |
||||
|
using ModelContextProtocol.Protocol; |
||||
|
using ModelContextProtocol.Server; |
||||
|
using Volo.Abp.Cli.Commands.Models; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Services; |
||||
|
|
||||
|
public class McpServerService : ITransientDependency |
||||
|
{ |
||||
|
private const string LogSource = nameof(McpServerService); |
||||
|
private const int MaxLogResponseLength = 500; |
||||
|
|
||||
|
private static readonly JsonSerializerOptions JsonCamelCaseOptions = new() |
||||
|
{ |
||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase |
||||
|
}; |
||||
|
|
||||
|
private static class ToolErrorMessages |
||||
|
{ |
||||
|
public const string InvalidResponseFormat = "The tool execution completed but returned an invalid response format. Please try again."; |
||||
|
public const string UnexpectedError = "The tool execution failed due to an unexpected error. Please try again later."; |
||||
|
} |
||||
|
|
||||
|
private readonly McpHttpClientService _mcpHttpClient; |
||||
|
private readonly McpToolsCacheService _toolsCacheService; |
||||
|
private readonly IMcpLogger _mcpLogger; |
||||
|
|
||||
|
public McpServerService( |
||||
|
McpHttpClientService mcpHttpClient, |
||||
|
McpToolsCacheService toolsCacheService, |
||||
|
IMcpLogger mcpLogger) |
||||
|
{ |
||||
|
_mcpHttpClient = mcpHttpClient; |
||||
|
_toolsCacheService = toolsCacheService; |
||||
|
_mcpLogger = mcpLogger; |
||||
|
} |
||||
|
|
||||
|
public async Task RunAsync(CancellationToken cancellationToken = default) |
||||
|
{ |
||||
|
_mcpLogger.Info(LogSource, "Starting ABP MCP Server (stdio)"); |
||||
|
|
||||
|
var options = new McpServerOptions(); |
||||
|
|
||||
|
await RegisterAllToolsAsync(options); |
||||
|
|
||||
|
// Use NullLoggerFactory to prevent ModelContextProtocol library from logging to stdout
|
||||
|
// All our logging goes to file and stderr via IMcpLogger
|
||||
|
var server = McpServer.Create( |
||||
|
new StdioServerTransport("abp-mcp-server", NullLoggerFactory.Instance), |
||||
|
options |
||||
|
); |
||||
|
|
||||
|
await server.RunAsync(cancellationToken); |
||||
|
|
||||
|
_mcpLogger.Info(LogSource, "ABP MCP Server stopped"); |
||||
|
} |
||||
|
|
||||
|
private async Task RegisterAllToolsAsync(McpServerOptions options) |
||||
|
{ |
||||
|
// Get tool definitions from cache (or fetch from server)
|
||||
|
var toolDefinitions = await _toolsCacheService.GetToolDefinitionsAsync(); |
||||
|
|
||||
|
_mcpLogger.Info(LogSource, $"Registering {toolDefinitions.Count} tools"); |
||||
|
|
||||
|
// Register each tool dynamically
|
||||
|
foreach (var toolDef in toolDefinitions) |
||||
|
{ |
||||
|
RegisterToolFromDefinition(options, toolDef); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void RegisterToolFromDefinition(McpServerOptions options, McpToolDefinition toolDef) |
||||
|
{ |
||||
|
var inputSchema = toolDef.InputSchema ?? new McpToolInputSchema(); |
||||
|
RegisterTool(options, toolDef.Name, toolDef.Description, inputSchema, toolDef.OutputSchema); |
||||
|
} |
||||
|
|
||||
|
private static CallToolResult CreateErrorResult(string errorMessage) |
||||
|
{ |
||||
|
return new CallToolResult |
||||
|
{ |
||||
|
Content = new List<ContentBlock> |
||||
|
{ |
||||
|
new TextContentBlock |
||||
|
{ |
||||
|
Text = errorMessage |
||||
|
} |
||||
|
}, |
||||
|
IsError = true |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private void RegisterTool( |
||||
|
McpServerOptions options, |
||||
|
string name, |
||||
|
string description, |
||||
|
object inputSchema, |
||||
|
JsonElement? outputSchema) |
||||
|
{ |
||||
|
if (options.ToolCollection == null) |
||||
|
{ |
||||
|
options.ToolCollection = new McpServerPrimitiveCollection<McpServerTool>(); |
||||
|
} |
||||
|
|
||||
|
var tool = new AbpMcpServerTool( |
||||
|
name, |
||||
|
description, |
||||
|
JsonSerializer.SerializeToElement(inputSchema, JsonCamelCaseOptions), |
||||
|
outputSchema, |
||||
|
(context, cancellationToken) => HandleToolInvocationAsync(name, context, cancellationToken) |
||||
|
); |
||||
|
|
||||
|
options.ToolCollection.Add(tool); |
||||
|
} |
||||
|
|
||||
|
private async ValueTask<CallToolResult> HandleToolInvocationAsync( |
||||
|
string toolName, |
||||
|
RequestContext<CallToolRequestParams> context, |
||||
|
CancellationToken cancellationToken) |
||||
|
{ |
||||
|
_mcpLogger.Debug(LogSource, $"Tool '{toolName}' called with arguments: {context.Params.Arguments}"); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
var argumentsJson = JsonSerializer.SerializeToElement(context.Params.Arguments); |
||||
|
var resultJson = await _mcpHttpClient.CallToolAsync(toolName, argumentsJson); |
||||
|
|
||||
|
var callToolResult = TryDeserializeResult(resultJson, toolName); |
||||
|
if (callToolResult != null) |
||||
|
{ |
||||
|
LogToolResult(toolName, callToolResult); |
||||
|
return callToolResult; |
||||
|
} |
||||
|
|
||||
|
return CreateErrorResult(ToolErrorMessages.InvalidResponseFormat); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, $"Tool '{toolName}' execution failed '{ex.Message}'", ex); |
||||
|
return CreateErrorResult(ToolErrorMessages.UnexpectedError); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private CallToolResult TryDeserializeResult(string resultJson, string toolName) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
return JsonSerializer.Deserialize<CallToolResult>(resultJson); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, $"Failed to deserialize response as CallToolResult: {ex.Message}"); |
||||
|
|
||||
|
var logResponse = resultJson.Length <= MaxLogResponseLength |
||||
|
? resultJson |
||||
|
: resultJson.Substring(0, MaxLogResponseLength); |
||||
|
_mcpLogger.Debug(LogSource, $"Response was: {logResponse}"); |
||||
|
|
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void LogToolResult(string toolName, CallToolResult result) |
||||
|
{ |
||||
|
if (result.IsError == true) |
||||
|
{ |
||||
|
_mcpLogger.Warning(LogSource, $"Tool '{toolName}' returned an error"); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
_mcpLogger.Debug(LogSource, $"Tool '{toolName}' executed successfully"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,183 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Globalization; |
||||
|
using System.IO; |
||||
|
using System.Runtime.InteropServices; |
||||
|
using System.Text.Json; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Volo.Abp.Cli.Commands.Models; |
||||
|
using Volo.Abp.Cli.Memory; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Volo.Abp.IO; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Services; |
||||
|
|
||||
|
public class McpToolsCacheService : ITransientDependency |
||||
|
{ |
||||
|
private const string LogSource = nameof(McpToolsCacheService); |
||||
|
private const int CacheValidityHours = 24; |
||||
|
|
||||
|
private readonly McpHttpClientService _mcpHttpClient; |
||||
|
private readonly MemoryService _memoryService; |
||||
|
private readonly ILogger<McpToolsCacheService> _logger; |
||||
|
private readonly IMcpLogger _mcpLogger; |
||||
|
|
||||
|
public McpToolsCacheService( |
||||
|
McpHttpClientService mcpHttpClient, |
||||
|
MemoryService memoryService, |
||||
|
ILogger<McpToolsCacheService> logger, |
||||
|
IMcpLogger mcpLogger) |
||||
|
{ |
||||
|
_mcpHttpClient = mcpHttpClient; |
||||
|
_memoryService = memoryService; |
||||
|
_logger = logger; |
||||
|
_mcpLogger = mcpLogger; |
||||
|
} |
||||
|
|
||||
|
public async Task<List<McpToolDefinition>> GetToolDefinitionsAsync() |
||||
|
{ |
||||
|
if (await IsCacheValidAsync()) |
||||
|
{ |
||||
|
var cachedTools = await LoadFromCacheAsync(); |
||||
|
if (cachedTools != null) |
||||
|
{ |
||||
|
_mcpLogger.Debug(LogSource, "Using cached tool definitions"); |
||||
|
// Initialize the HTTP client's tool names list from cache
|
||||
|
_mcpHttpClient.InitializeToolNames(cachedTools); |
||||
|
return cachedTools; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Cache is invalid or missing, fetch from server
|
||||
|
_mcpLogger.Info(LogSource, "Fetching tool definitions from server..."); |
||||
|
var tools = await _mcpHttpClient.GetToolDefinitionsAsync(); |
||||
|
|
||||
|
// Validate that we got tools
|
||||
|
if (tools == null || tools.Count == 0) |
||||
|
{ |
||||
|
throw new CliUsageException( |
||||
|
"Failed to fetch tool definitions from ABP.IO MCP Server. " + |
||||
|
"No tools available. The MCP server cannot start without tool definitions."); |
||||
|
} |
||||
|
|
||||
|
// Save tools to cache
|
||||
|
await SaveToCacheAsync(tools); |
||||
|
await _memoryService.SetAsync(CliConsts.MemoryKeys.McpToolsLastFetchDate, DateTime.Now.ToString(CultureInfo.InvariantCulture)); |
||||
|
|
||||
|
_mcpLogger.Info(LogSource, $"Successfully fetched and cached {tools.Count} tool definitions"); |
||||
|
return tools; |
||||
|
} |
||||
|
|
||||
|
private async Task<bool> IsCacheValidAsync() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
// Check if cache file exists
|
||||
|
if (!File.Exists(CliPaths.McpToolsCache)) |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// Check timestamp in memory
|
||||
|
var lastFetchTimeString = await _memoryService.GetAsync(CliConsts.MemoryKeys.McpToolsLastFetchDate); |
||||
|
if (string.IsNullOrEmpty(lastFetchTimeString)) |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (DateTime.TryParse(lastFetchTimeString, CultureInfo.InvariantCulture, DateTimeStyles.None, out var lastFetchTime)) |
||||
|
{ |
||||
|
// Check if less than configured hours old
|
||||
|
if (DateTime.Now.Subtract(lastFetchTime).TotalHours < CacheValidityHours) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger.LogWarning($"Error checking cache validity: {ex.Message}"); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task<List<McpToolDefinition>> LoadFromCacheAsync() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
if (!File.Exists(CliPaths.McpToolsCache)) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
var json = await FileHelper.ReadAllTextAsync(CliPaths.McpToolsCache); |
||||
|
var tools = JsonSerializer.Deserialize<List<McpToolDefinition>>(json, new JsonSerializerOptions |
||||
|
{ |
||||
|
PropertyNameCaseInsensitive = true |
||||
|
}); |
||||
|
|
||||
|
return tools; |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger.LogWarning($"Error loading cached tool definitions: {ex.Message}"); |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private Task SaveToCacheAsync(List<McpToolDefinition> tools) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
// Ensure directory exists
|
||||
|
var directory = Path.GetDirectoryName(CliPaths.McpToolsCache); |
||||
|
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) |
||||
|
{ |
||||
|
Directory.CreateDirectory(directory); |
||||
|
} |
||||
|
|
||||
|
var json = JsonSerializer.Serialize(tools, new JsonSerializerOptions |
||||
|
{ |
||||
|
WriteIndented = true, |
||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase |
||||
|
}); |
||||
|
|
||||
|
// Using synchronous File.WriteAllText is acceptable here since cache writes are not on the critical path
|
||||
|
// and we need to support multiple target frameworks
|
||||
|
File.WriteAllText(CliPaths.McpToolsCache, json); |
||||
|
|
||||
|
// Set restrictive file permissions (user read/write only)
|
||||
|
SetRestrictiveFilePermissions(CliPaths.McpToolsCache); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger.LogWarning($"Error saving tool definitions to cache: {ex.Message}"); |
||||
|
} |
||||
|
|
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
|
||||
|
private void SetRestrictiveFilePermissions(string filePath) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
// On Unix systems, set permissions to 600 (user read/write only)
|
||||
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) |
||||
|
{ |
||||
|
#if NET6_0_OR_GREATER
|
||||
|
File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); |
||||
|
#endif
|
||||
|
} |
||||
|
// On Windows, the file inherits permissions from the user profile directory,
|
||||
|
// which is already restrictive to the current user
|
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger.LogWarning($"Error setting file permissions: {ex.Message}"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,22 @@ |
|||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using Hangfire; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
using Volo.Abp.BackgroundWorkers; |
||||
|
using Volo.Abp.Threading; |
||||
|
|
||||
|
namespace Volo.Abp.BackgroundJobs.DemoApp.HangFire; |
||||
|
|
||||
|
public class TestWorker : AsyncPeriodicBackgroundWorkerBase |
||||
|
{ |
||||
|
public TestWorker(AbpAsyncTimer timer, IServiceScopeFactory serviceScopeFactory) |
||||
|
: base(timer, serviceScopeFactory) |
||||
|
{ |
||||
|
CronExpression = Cron.Minutely(); |
||||
|
} |
||||
|
|
||||
|
protected override async Task DoWorkAsync(PeriodicBackgroundWorkerContext workerContext) |
||||
|
{ |
||||
|
Console.WriteLine($"[{DateTime.Now}] TestWorker executed."); |
||||
|
} |
||||
|
} |
||||