@ -0,0 +1,645 @@ |
|||
Multi-tenancy sounds simple until the first real requirement lands: tenant-specific data isolation, host-only features, separate databases for a few big customers, and an admin panel that still feels like one product. |
|||
|
|||
ABP Framework gives you most of the plumbing out of the box, but the important part is knowing which pieces to enable, which defaults to trust, and where teams usually get into trouble. This article walks through a practical implementation approach for ABP Framework v8+ and the latest branch, covering shared database, separate database, and hybrid setups. |
|||
|
|||
## What ABP Multi-Tenancy Actually Gives You |
|||
|
|||
ABP's multi-tenancy support is not just a `TenantId` convention. It includes: |
|||
|
|||
- tenant context management |
|||
- automatic data filtering for multi-tenant entities |
|||
- tenant resolution from web requests |
|||
- host vs tenant side separation |
|||
- permission scoping by tenancy side |
|||
- tenant-aware connection string resolution |
|||
- tenant management infrastructure |
|||
|
|||
The first switch is explicit. |
|||
|
|||
```csharp |
|||
Configure<AbpMultiTenancyOptions>(options => |
|||
{ |
|||
options.IsEnabled = true; |
|||
}); |
|||
``` |
|||
|
|||
Technically, multi-tenancy is disabled by default, although ABP startup templates usually enable it for you. |
|||
|
|||
ABP models two sides: |
|||
|
|||
- Host: the system owner, platform operator, or SaaS provider |
|||
- Tenant: the customer using the system |
|||
|
|||
A `TenantId` value of `null` typically means the data belongs to the host side. |
|||
|
|||
## Choose the Right Database Architecture First |
|||
|
|||
Before writing entities or resolvers, decide how tenant data will be stored. This choice affects migrations, operations, support cost, and sometimes your pricing model. |
|||
|
|||
### 1. Shared Database |
|||
|
|||
All tenants share the same database and tables. Isolation is enforced with `TenantId` and ABP's built-in data filters. |
|||
|
|||
Why teams choose it: |
|||
|
|||
- simplest deployment model |
|||
- lowest infrastructure cost |
|||
- easiest to operate in early-stage SaaS products |
|||
- one migration pipeline |
|||
|
|||
Trade-offs: |
|||
|
|||
- large tables grow quickly |
|||
- indexing becomes more important |
|||
- noisy-neighbor performance is more likely |
|||
- stricter discipline is required to avoid cross-tenant mistakes |
|||
|
|||
This is usually the best default unless you already know you need stronger isolation. |
|||
|
|||
### 2. Separate Database per Tenant |
|||
|
|||
Each tenant gets its own database. Host data is usually kept in a central database, while tenant-specific data goes to per-tenant databases. |
|||
|
|||
Why teams choose it: |
|||
|
|||
- stronger data isolation |
|||
- easier tenant-specific backup and restore |
|||
- cleaner compliance story |
|||
- large tenants can scale independently |
|||
|
|||
Trade-offs: |
|||
|
|||
- more provisioning logic |
|||
- more migration complexity |
|||
- more operational overhead |
|||
- onboarding a tenant is no longer just inserting a row |
|||
|
|||
### 3. Hybrid Model |
|||
|
|||
Some tenants use the shared database, while others get dedicated databases. |
|||
|
|||
This is often the most realistic long-term model: |
|||
|
|||
- small customers stay in shared infrastructure |
|||
- enterprise customers get isolated databases |
|||
- you can promote selected tenants later |
|||
|
|||
Trade-offs: |
|||
|
|||
- highest implementation and operational complexity |
|||
- migrations and seeding need stronger discipline |
|||
- debugging environment-specific issues becomes harder |
|||
|
|||
|
|||
|
|||
 |
|||
|
|||
## Implement Tenant-Aware Entities Correctly |
|||
|
|||
In ABP, tenant-scoped entities implement `IMultiTenant`. |
|||
|
|||
```csharp |
|||
using Volo.Abp.Domain.Entities; |
|||
using Volo.Abp.MultiTenancy; |
|||
|
|||
public class Product : AggregateRoot<Guid>, IMultiTenant |
|||
{ |
|||
public Guid? TenantId { get; set; } |
|||
public string Name { get; private set; } |
|||
public decimal Price { get; private set; } |
|||
|
|||
private Product() |
|||
{ |
|||
} |
|||
|
|||
public Product(Guid id, string name, decimal price, Guid? tenantId) |
|||
: base(id) |
|||
{ |
|||
TenantId = tenantId; |
|||
Name = name; |
|||
Price = price; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
Once an entity implements `IMultiTenant`, ABP automatically filters queries according to the current tenant. |
|||
|
|||
That means this kind of repository call is already tenant-aware in normal application flow: |
|||
|
|||
```csharp |
|||
var products = await _productRepository.GetListAsync(); |
|||
``` |
|||
|
|||
### The nullable `TenantId` detail matters |
|||
|
|||
`TenantId` is nullable by design because host-owned data is valid in ABP. |
|||
|
|||
That is useful, but also easy to misuse. |
|||
|
|||
If an entity is truly tenant-only, do not casually allow `TenantId = null`. Enforce the rule in your constructor, factory method, or domain service. |
|||
|
|||
Example: |
|||
|
|||
```csharp |
|||
public Order(Guid id, Guid tenantId, string orderNo) : base(id) |
|||
{ |
|||
TenantId = tenantId; |
|||
OrderNo = orderNo; |
|||
} |
|||
|
|||
public Guid? TenantId { get; private set; } |
|||
public string OrderNo { get; private set; } |
|||
``` |
|||
|
|||
For tenant-only aggregates, this small constraint prevents a surprising number of data leakage bugs. |
|||
|
|||
## Use `ICurrentTenant` for Context-Aware Logic |
|||
|
|||
`ICurrentTenant` is the central service for reading or temporarily changing tenant context. |
|||
|
|||
```csharp |
|||
public class ProductAppService : ApplicationService |
|||
{ |
|||
public async Task<string> GetTenantInfoAsync() |
|||
{ |
|||
if (CurrentTenant.IsAvailable) |
|||
{ |
|||
return $"TenantId: {CurrentTenant.Id}, Name: {CurrentTenant.Name}"; |
|||
} |
|||
|
|||
return "Host context"; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
The more interesting capability is context switching. |
|||
|
|||
```csharp |
|||
using (_currentTenant.Change(tenantId)) |
|||
{ |
|||
var count = await _productRepository.GetCountAsync(); |
|||
} |
|||
``` |
|||
|
|||
This is useful for: |
|||
|
|||
- background jobs that process one tenant at a time |
|||
- host-side reporting across tenants |
|||
- tenant seeding during onboarding |
|||
- maintenance tasks and migrations |
|||
|
|||
### A practical warning |
|||
|
|||
Switching tenant context is powerful. It is also a common source of subtle bugs when developers mix host and tenant operations in the same method. Keep tenant context scopes short and obvious. |
|||
|
|||
## How Tenant Resolution Works in ABP |
|||
|
|||
ABP determines the active tenant through a chain of tenant resolvers. Out of the box, the default contributors are checked in this order: |
|||
|
|||
1. Current user claims |
|||
2. Query string, using `__tenant` by default |
|||
3. Route value |
|||
4. Header |
|||
5. Cookie |
|||
|
|||
In practice, this means a request can become tenant-aware even before your application service runs. |
|||
|
|||
### Default key configuration |
|||
|
|||
If you want to change the default `__tenant` key: |
|||
|
|||
```csharp |
|||
Configure<AbpAspNetCoreMultiTenancyOptions>(options => |
|||
{ |
|||
options.TenantKey = "tenant"; |
|||
}); |
|||
``` |
|||
|
|||
This is fine, but if you have a frontend client, especially Angular, the client must use the same tenant key. Otherwise the backend and frontend silently disagree about tenant resolution. |
|||
|
|||
### Domain and subdomain based resolution |
|||
|
|||
ABP also supports domain or subdomain-based tenant resolution. |
|||
|
|||
```csharp |
|||
Configure<AbpTenantResolveOptions>(options => |
|||
{ |
|||
options.AddDomainTenantResolver("{0}.myapp.com"); |
|||
}); |
|||
``` |
|||
|
|||
This is usually the cleanest user experience for SaaS applications because the tenant is implied by the hostname. |
|||
|
|||
Use it when: |
|||
|
|||
- each tenant has a branded subdomain |
|||
- the URL should define tenant context naturally |
|||
- you want fewer explicit tenant parameters in requests |
|||
|
|||
Be careful with: |
|||
|
|||
- reverse proxies and forwarded headers |
|||
- wildcard DNS and TLS certificates |
|||
- authentication server issuer validation in wildcard domain scenarios |
|||
- local development setup |
|||
|
|||
If you use OpenIddict or token validation with wildcard domains, make sure issuer validation is configured for that pattern. This is one of the most common production surprises in subdomain-based multi-tenant setups. |
|||
|
|||
### Fallback tenant |
|||
|
|||
ABP can also use a fallback tenant. |
|||
|
|||
That can be convenient in development or in a constrained deployment model, but it comes with an important trade-off: you effectively reduce or hide host context behavior. Use fallback tenants deliberately, not as a shortcut for resolver problems. |
|||
|
|||
|
|||
|
|||
 |
|||
|
|||
## Custom Tenant Resolvers for Real Projects |
|||
|
|||
Sooner or later, one tenant comes from a gateway header, another from a custom route pattern, and a third from a legacy integration. |
|||
|
|||
ABP allows custom tenant resolvers by implementing a contributor. |
|||
|
|||
```csharp |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.MultiTenancy; |
|||
|
|||
public class XTenantHeaderResolveContributor : TenantResolveContributorBase |
|||
{ |
|||
public const string HeaderName = "X-Tenant-Code"; |
|||
|
|||
public override string Name => "XTenantHeader"; |
|||
|
|||
public override Task ResolveAsync(ITenantResolveContext context) |
|||
{ |
|||
var httpContext = context.GetHttpContext(); |
|||
var tenantCode = httpContext?.Request.Headers[HeaderName].ToString(); |
|||
|
|||
if (!tenantCode.IsNullOrWhiteSpace()) |
|||
{ |
|||
context.Handled = true; |
|||
context.TenantIdOrName = tenantCode; |
|||
} |
|||
|
|||
return Task.CompletedTask; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
Then register it in tenant resolve options. |
|||
|
|||
The main rule here is simple: prefer one primary strategy. A long resolver chain with multiple overlapping sources makes support harder. |
|||
|
|||
## Configure `DbContext` for Host and Tenant Sides |
|||
|
|||
When you move beyond a single shared database, `DbContext` design becomes a core architecture decision. |
|||
|
|||
ABP supports defining which side a context belongs to: |
|||
|
|||
- `Both` |
|||
- `Host` |
|||
- `Tenant` |
|||
|
|||
This matters when you want host-only tables to stay out of tenant databases, or when tenant databases should contain only selected modules. |
|||
|
|||
### Why this matters |
|||
|
|||
Suppose your host side includes tenant management, audit administration, and platform billing, but tenant databases should only include business tables and tenant-facing identity data. |
|||
|
|||
If you blindly configure every module in every context, your tenant databases will accumulate tables they should never have had. |
|||
|
|||
### Practical approach |
|||
|
|||
For a shared database setup, one `DbContext` with `Both` is often enough. |
|||
|
|||
For separate or hybrid databases, a common approach is: |
|||
|
|||
- one host/shared `DbContext` |
|||
- one tenant-only `DbContext` |
|||
- selective module configuration per context |
|||
|
|||
The important implementation detail is not just the side flag. It is also controlling which `builder.ConfigureXyz()` calls are applied in each context. |
|||
|
|||
For example, do not configure host-only modules in the tenant-only context. |
|||
|
|||
## Shared Database Setup: The Best Starting Point |
|||
|
|||
If you are implementing multi-tenancy for the first time in ABP, start with the shared database model unless you have a strong reason not to. |
|||
|
|||
A practical setup looks like this: |
|||
|
|||
1. Enable multi-tenancy |
|||
2. Make tenant-owned entities implement `IMultiTenant` |
|||
3. Use standard ABP repositories |
|||
4. Resolve tenant from user, subdomain, or request key |
|||
5. Keep host-owned data with `TenantId = null` |
|||
6. Define permissions with proper tenancy sides |
|||
|
|||
Example entity creation inside a tenant context: |
|||
|
|||
```csharp |
|||
public class ProductManager : DomainService |
|||
{ |
|||
public async Task<Product> CreateAsync(string name, decimal price) |
|||
{ |
|||
var product = new Product( |
|||
GuidGenerator.Create(), |
|||
name, |
|||
price, |
|||
CurrentTenant.Id |
|||
); |
|||
|
|||
return await _productRepository.InsertAsync(product); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
This works well because ABP naturally fills the application flow with tenant context. |
|||
|
|||
### Performance tips for shared database mode |
|||
|
|||
As tenant count grows: |
|||
|
|||
- index `TenantId` on large tables |
|||
- include `TenantId` in common query patterns |
|||
- monitor large shared tables early |
|||
- be careful with cross-tenant reporting queries |
|||
- verify all custom SQL is tenant-aware |
|||
|
|||
ABP helps with filtering, but it does not replace database design. |
|||
|
|||
## Separate Database per Tenant in ABP |
|||
|
|||
This is where ABP becomes especially useful, because it can resolve the active tenant and then use tenant-specific connection strings. |
|||
|
|||
The Tenant Management module stores tenant metadata, including optional connection strings. |
|||
|
|||
At a high level, the flow is: |
|||
|
|||
1. Resolve the current tenant |
|||
2. Load tenant configuration |
|||
3. Determine the right connection string |
|||
4. Build the `DbContext` against the host or tenant database |
|||
5. Apply data filters inside that database scope as needed |
|||
|
|||
### What is available out of the box |
|||
|
|||
ABP supports the architecture and connection-string-based separation. |
|||
|
|||
Version-wise, the latest ABP docs reflect improved support in open source for separate database per tenant. However, managing tenant connection strings from the UI remains tied to SaaS/PRO features. In open source, teams often provide this through custom admin screens, configuration management, or provisioning services. |
|||
|
|||
### What changes operationally |
|||
|
|||
With per-tenant databases, you now need a plan for: |
|||
|
|||
- database creation during tenant onboarding |
|||
- migrations for new and existing tenant databases |
|||
- tenant-specific seeding |
|||
- backups and restore procedures |
|||
- monitoring failed or drifted tenant databases |
|||
|
|||
This is the real cost of stronger isolation. |
|||
|
|||
|
|||
|
|||
 |
|||
|
|||
## Hybrid Multi-Tenancy: Shared by Default, Dedicated When Needed |
|||
|
|||
Hybrid architecture is often the most business-friendly model. |
|||
|
|||
A common pattern looks like this: |
|||
|
|||
- default all new tenants to shared database |
|||
- move larger or regulated tenants to dedicated databases |
|||
- keep host/platform data in a central database |
|||
|
|||
This lets you defer infrastructure cost until a tenant actually needs isolation. |
|||
|
|||
The challenge is not whether ABP supports it. It does. The challenge is operational consistency: |
|||
|
|||
- how a tenant is promoted from shared to dedicated |
|||
- how data is moved safely |
|||
- how migrations stay aligned across both models |
|||
- how support engineers know which storage model a tenant uses |
|||
|
|||
If you choose hybrid, document the lifecycle, not just the code. |
|||
|
|||
## Tenant Management, Onboarding, and Connection Strings |
|||
|
|||
ABP's Tenant Management module is the starting point for tenant administration. |
|||
|
|||
It gives you tenant records and a standard place to store metadata. In more advanced solutions, that metadata is often extended with: |
|||
|
|||
- edition or plan |
|||
- onboarding status |
|||
- provisioning result |
|||
- custom domains |
|||
- support tier |
|||
- infrastructure notes |
|||
|
|||
For separate database scenarios, onboarding usually means more than creating a tenant row. It often includes: |
|||
|
|||
1. create tenant record |
|||
2. assign connection string if needed |
|||
3. create database or schema |
|||
4. run migrations |
|||
5. seed tenant data |
|||
6. create admin user |
|||
7. confirm domain or resolver setup |
|||
|
|||
Treat onboarding as a workflow, not a controller action. |
|||
|
|||
## Permissions and Authorization in a Multi-Tenant App |
|||
|
|||
ABP permissions can be scoped with `MultiTenancySides`. |
|||
|
|||
That is important because host users and tenant users often should not even see the same capabilities. |
|||
|
|||
Example definition: |
|||
|
|||
```csharp |
|||
context.AddGroup(MyPermissions.GroupName) |
|||
.AddPermission( |
|||
MyPermissions.HostDashboard, |
|||
multiTenancySide: MultiTenancySides.Host |
|||
) |
|||
.AddPermission( |
|||
MyPermissions.TenantDashboard, |
|||
multiTenancySide: MultiTenancySides.Tenant |
|||
); |
|||
``` |
|||
|
|||
This is one of the easiest wins in ABP multi-tenancy. Use it early. |
|||
|
|||
### Why it matters in practice |
|||
|
|||
Without side-aware permission definitions: |
|||
|
|||
- host-only menus can appear in tenant UI |
|||
- tenant-only features can leak into host administration |
|||
- tests become confusing because behavior differs by login context |
|||
|
|||
Also remember that usernames can collide across tenants. That is normal in multi-tenant identity models. What matters is the combination of user identity and tenant context. |
|||
|
|||
## Migrations and Data Seeding Without Regret |
|||
|
|||
Multi-tenant EF Core migrations are straightforward in theory and messy in real systems if you skip the design phase. |
|||
|
|||
### Shared database |
|||
|
|||
This is simplest: |
|||
|
|||
- one database |
|||
- one main migration flow |
|||
- host and tenant data usually seeded into the same database with different contexts or `TenantId` semantics |
|||
|
|||
### Separate or hybrid databases |
|||
|
|||
Now you need to answer: |
|||
|
|||
- which context owns which schema |
|||
- which migration runs against host DB |
|||
- which migration runs against tenant DBs |
|||
- when new tenants receive schema updates |
|||
- how failed migrations are retried |
|||
|
|||
### Seeding strategy |
|||
|
|||
A practical model is: |
|||
|
|||
- seed host-level data in the host database |
|||
- seed tenant defaults when a tenant is created |
|||
- perform tenant seeding inside `CurrentTenant.Change(tenantId)` scopes where appropriate |
|||
|
|||
Example: |
|||
|
|||
```csharp |
|||
using (_currentTenant.Change(tenantId)) |
|||
{ |
|||
await _dataSeeder.SeedAsync(new DataSeedContext(tenantId)); |
|||
} |
|||
``` |
|||
|
|||
That keeps seeding logic tenant-aware and consistent with the rest of the application. |
|||
|
|||
## Common Pitfalls That Break Multi-Tenancy |
|||
|
|||
Most ABP multi-tenancy bugs are not framework bugs. They are design mistakes. |
|||
|
|||
### 1. Tenant-only entity accidentally allows host ownership |
|||
|
|||
If `TenantId` stays nullable for a strictly tenant-owned entity, host-side records can slip in. That often leads to confusing query behavior and data mixing. |
|||
|
|||
### 2. Custom SQL bypasses tenant filtering |
|||
|
|||
ABP filters repository and LINQ queries for `IMultiTenant` entities. Your raw SQL does not magically become safe. Always include tenant scope explicitly when writing custom SQL. |
|||
|
|||
### 3. Host-only modules end up in tenant databases |
|||
|
|||
This usually happens when all module mappings are copied into every `DbContext`. Be intentional about which modules are configured where. |
|||
|
|||
### 4. Resolver strategy is inconsistent |
|||
|
|||
For example: |
|||
|
|||
- frontend sends `tenant` |
|||
- backend expects `__tenant` |
|||
- API gateway injects a header |
|||
- auth claims still refer to a different tenant source |
|||
|
|||
You can spend hours debugging what is really just inconsistent tenant resolution. |
|||
|
|||
### 5. Subdomain authentication is not fully configured |
|||
|
|||
Wildcard domains, issuer validation, proxy headers, and cookie domains all need a coherent setup. Subdomain multi-tenancy is elegant, but only after it is fully wired. |
|||
|
|||
### 6. Shared database performance is ignored too long |
|||
|
|||
If every large table relies on `TenantId` filters, indexing and query shape matter. This usually becomes painful gradually, then suddenly. |
|||
|
|||
## When to Use Shared, Separate, or Hybrid |
|||
|
|||
### Use shared database when |
|||
|
|||
- you are building a standard SaaS product |
|||
- operational simplicity matters most |
|||
- tenants are relatively small |
|||
- strict physical isolation is not required |
|||
- you want the fastest path to production |
|||
|
|||
### Use separate databases when |
|||
|
|||
- customers require stronger isolation |
|||
- you need tenant-level backup and restore |
|||
- data volume varies significantly between tenants |
|||
- some tenants need independent scaling or maintenance windows |
|||
- compliance requirements push you there |
|||
|
|||
### Use hybrid when |
|||
|
|||
- most tenants fit shared infrastructure |
|||
- a few enterprise tenants need dedicated storage |
|||
- you want to defer cost while preserving an upgrade path |
|||
- your team can handle extra migration and operational complexity |
|||
|
|||
### When NOT to over-engineer it |
|||
|
|||
Do not start with hybrid just because it sounds flexible. |
|||
|
|||
If you are early-stage and do not yet have hard isolation requirements, shared database with good tenant discipline is usually the better engineering decision. |
|||
|
|||
## A Practical Implementation Plan |
|||
|
|||
If you want a sane rollout path, use this order: |
|||
|
|||
### Phase 1: Enable and model multi-tenancy |
|||
|
|||
- enable `AbpMultiTenancyOptions` |
|||
- implement `IMultiTenant` on tenant-owned entities |
|||
- review aggregate rules around nullable `TenantId` |
|||
- define host vs tenant permissions correctly |
|||
|
|||
### Phase 2: Pick one tenant resolution strategy |
|||
|
|||
- prefer subdomain or authenticated user claim for web apps |
|||
- keep request key resolution for APIs or development |
|||
- make frontend and backend tenant key configuration consistent |
|||
|
|||
### Phase 3: Start with shared database |
|||
|
|||
- launch with shared DB unless requirements force separation |
|||
- add indexes and monitoring early |
|||
- verify custom queries are tenant-safe |
|||
|
|||
### Phase 4: Prepare for separation only where needed |
|||
|
|||
- isolate `DbContext` boundaries cleanly |
|||
- separate host-only module configuration from tenant-only configuration |
|||
- design onboarding and migration workflows |
|||
- add support for per-tenant connection strings when the business actually needs it |
|||
|
|||
This path keeps your first release simple without blocking future isolation models. |
|||
|
|||
## Final Thoughts |
|||
|
|||
ABP Framework removes a lot of the repetitive work in multi-tenant .NET applications, but it does not remove architectural choices. You still need to decide how tenants are resolved, where data lives, which modules belong to which side, and how strict your isolation really needs to be. |
|||
|
|||
The best ABP multi-tenancy setups are usually boring in the right places: |
|||
|
|||
- one clear tenant resolution strategy |
|||
- strict entity rules |
|||
- explicit host vs tenant boundaries |
|||
- a simple default database model |
|||
- operational workflows designed before enterprise tenants arrive |
|||
|
|||
That is what keeps a multi-tenant system maintainable after the demo phase. |
|||
|
|||
## TL;DR |
|||
|
|||
- Enable ABP multi-tenancy explicitly, then model tenant-owned entities with `IMultiTenant` and disciplined `TenantId` rules. |
|||
- Start with a shared database unless you already need stronger isolation, compliance, or tenant-level scaling. |
|||
- Use `ICurrentTenant` and a clear tenant resolution strategy to keep application logic predictable. |
|||
- For separate or hybrid databases, control `DbContext` boundaries, module mappings, migrations, and onboarding workflows carefully. |
|||
- Define permissions with `MultiTenancySides` so host and tenant experiences stay clean and secure. |
|||
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 955 KiB |
|
After Width: | Height: | Size: 1006 KiB |
@ -0,0 +1,607 @@ |
|||
Background jobs are one of those features that look simple at first and become operationally important very quickly. Sending emails, generating reports, syncing with third-party APIs, cleaning expired data, and processing imports should not block your HTTP requests. |
|||
|
|||
ABP gives you a clean abstraction for background jobs, and Hangfire gives you a production-friendly execution engine with persistence, retries, queues, and a dashboard. The useful part is that you can keep your application code aligned with ABP’s abstractions while swapping in Hangfire as the actual runner. |
|||
|
|||
In this article, I’ll walk through how to implement background jobs with ABP and Hangfire, when to use each piece, and where teams usually get tripped up. |
|||
|
|||
## Why use Hangfire instead of ABP's default background job manager? |
|||
|
|||
ABP already has a built-in background job system, and it is perfectly fine for simple cases. But it helps to understand what you are trading. |
|||
|
|||
### ABP default job manager |
|||
|
|||
By default, ABP background jobs are: |
|||
|
|||
- Enqueued through `IBackgroundJobManager` |
|||
- Executed in-process |
|||
- FIFO-oriented |
|||
- Single-threaded by default |
|||
- Retried automatically with increasing delays |
|||
- Stored through ABP's background job store |
|||
|
|||
This is good when: |
|||
|
|||
- Your app is small or moderate in workload |
|||
- You want minimal setup |
|||
- You do not need a dashboard |
|||
- You do not need advanced queue management |
|||
|
|||
### Hangfire integration |
|||
|
|||
When you add `Volo.Abp.BackgroundJobs.HangFire`, ABP can keep the same `IBackgroundJobManager` programming model, but Hangfire becomes the execution backend. |
|||
|
|||
That gives you: |
|||
|
|||
- Durable job storage |
|||
- Better operational visibility through the Hangfire dashboard |
|||
- Multiple worker servers |
|||
- Queue-based processing |
|||
- Recurring jobs and scheduling features |
|||
- A mature retry and monitoring model |
|||
|
|||
In practice, Hangfire is the better choice when background processing is part of the actual system design, not just a convenience. |
|||
|
|||
### Quick comparison |
|||
|
|||
Use ABP default when: |
|||
|
|||
- You want the simplest possible setup |
|||
- Background jobs are low volume |
|||
- A single app instance is enough |
|||
- You do not need a dashboard or queue controls |
|||
|
|||
Use Hangfire when: |
|||
|
|||
- You need reliability across restarts |
|||
- You run multiple instances |
|||
- You need recurring jobs or queue isolation |
|||
- You want to inspect failures and retries visually |
|||
- Background processing is operationally important |
|||
|
|||
|
|||
|
|||
 |
|||
|
|||
## Defining a background job in ABP |
|||
|
|||
The nice part of ABP is that your job code does not need to know about Hangfire. |
|||
|
|||
Start with a job arguments class: |
|||
|
|||
```csharp |
|||
public class EmailSendingArgs |
|||
{ |
|||
public string To { get; set; } = string.Empty; |
|||
public string Subject { get; set; } = string.Empty; |
|||
public string Body { get; set; } = string.Empty; |
|||
} |
|||
``` |
|||
|
|||
Then create the job itself: |
|||
|
|||
```csharp |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.BackgroundJobs; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
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.To, |
|||
args.Subject, |
|||
args.Body |
|||
); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
This job works with ABP’s job abstraction regardless of whether the runtime backend is the default implementation or Hangfire. |
|||
|
|||
To enqueue it: |
|||
|
|||
```csharp |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.BackgroundJobs; |
|||
|
|||
public class NotificationAppService : ApplicationService |
|||
{ |
|||
private readonly IBackgroundJobManager _backgroundJobManager; |
|||
|
|||
public NotificationAppService(IBackgroundJobManager backgroundJobManager) |
|||
{ |
|||
_backgroundJobManager = backgroundJobManager; |
|||
} |
|||
|
|||
public async Task QueueWelcomeEmailAsync(string email) |
|||
{ |
|||
await _backgroundJobManager.EnqueueAsync( |
|||
new EmailSendingArgs |
|||
{ |
|||
To = email, |
|||
Subject = "Welcome", |
|||
Body = "Your account is ready." |
|||
}, |
|||
priority: BackgroundJobPriority.Normal, |
|||
delay: TimeSpan.FromMinutes(1) |
|||
); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
A few practical notes: |
|||
|
|||
- `delay` is useful for short deferrals and back-office workflows. |
|||
- `priority` is part of ABP’s abstraction. How it maps operationally depends on the provider. |
|||
- Keep argument objects small and serializable. |
|||
- Do not pass EF entities or large object graphs into jobs. |
|||
|
|||
## Setting up Hangfire in an ABP application |
|||
|
|||
To integrate Hangfire, install the package and wire it into your ABP module. |
|||
|
|||
### 1. Add the package |
|||
|
|||
Using ABP CLI: |
|||
|
|||
```bash |
|||
abp add-package Volo.Abp.BackgroundJobs.HangFire |
|||
``` |
|||
|
|||
Or with NuGet: |
|||
|
|||
```bash |
|||
Install-Package Volo.Abp.BackgroundJobs.HangFire |
|||
``` |
|||
|
|||
### 2. Add the module dependency |
|||
|
|||
Typically this goes into your host module, such as `HttpApiHostModule`: |
|||
|
|||
```csharp |
|||
using Volo.Abp.BackgroundJobs.Hangfire; |
|||
|
|||
[DependsOn( |
|||
typeof(AbpBackgroundJobsHangfireModule) |
|||
)] |
|||
public class MyProjectHttpApiHostModule : AbpModule |
|||
{ |
|||
} |
|||
``` |
|||
|
|||
### 3. Configure Hangfire services |
|||
|
|||
In `ConfigureServices`: |
|||
|
|||
```csharp |
|||
using Hangfire; |
|||
using Microsoft.Extensions.Configuration; |
|||
|
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
var configuration = context.Services.GetConfiguration(); |
|||
|
|||
context.Services.AddHangfire(config => |
|||
{ |
|||
config.UseSqlServerStorage( |
|||
configuration.GetConnectionString("Default") |
|||
); |
|||
}); |
|||
} |
|||
``` |
|||
|
|||
If you use PostgreSQL, Redis, or another Hangfire storage provider, configure that instead. The storage decision matters because all servers that process jobs must share the same backing store. |
|||
|
|||
### 4. Enable the Hangfire dashboard |
|||
|
|||
In `OnApplicationInitialization`: |
|||
|
|||
```csharp |
|||
using Microsoft.AspNetCore.Builder; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
|
|||
public override void OnApplicationInitialization(ApplicationInitializationContext context) |
|||
{ |
|||
var app = context.GetApplicationBuilder(); |
|||
|
|||
app.UseAuthentication(); |
|||
app.UseAuthorization(); |
|||
|
|||
app.UseAbpHangfireDashboard(); |
|||
} |
|||
``` |
|||
|
|||
The dashboard middleware should be added after authentication and authorization middleware. |
|||
|
|||
At this point, jobs enqueued through `IBackgroundJobManager` should use Hangfire as long as the integration is correctly activated. |
|||
|
|||
|
|||
|
|||
 |
|||
|
|||
## End-to-end example: offloading a report export |
|||
|
|||
A common use case is exporting a report that may take several seconds or minutes. |
|||
|
|||
Instead of generating the file during the HTTP request: |
|||
|
|||
- Save an export request record |
|||
- Enqueue a background job |
|||
- Let the job generate the file |
|||
- Notify the user when it is ready |
|||
|
|||
### Arguments |
|||
|
|||
```csharp |
|||
public class ReportExportJobArgs |
|||
{ |
|||
public Guid ExportRequestId { get; set; } |
|||
public Guid UserId { get; set; } |
|||
} |
|||
``` |
|||
|
|||
### Job implementation |
|||
|
|||
```csharp |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.BackgroundJobs; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Uow; |
|||
|
|||
public class ReportExportJob : AsyncBackgroundJob<ReportExportJobArgs>, ITransientDependency |
|||
{ |
|||
private readonly IReportExportAppService _reportExportAppService; |
|||
|
|||
public ReportExportJob(IReportExportAppService reportExportAppService) |
|||
{ |
|||
_reportExportAppService = reportExportAppService; |
|||
} |
|||
|
|||
public override async Task ExecuteAsync(ReportExportJobArgs args) |
|||
{ |
|||
await _reportExportAppService.GenerateAsync(args.ExportRequestId, args.UserId); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### Enqueue from app service |
|||
|
|||
```csharp |
|||
public async Task<Guid> RequestExportAsync() |
|||
{ |
|||
var exportRequestId = GuidGenerator.Create(); |
|||
|
|||
await _backgroundJobManager.EnqueueAsync( |
|||
new ReportExportJobArgs |
|||
{ |
|||
ExportRequestId = exportRequestId, |
|||
UserId = CurrentUser.GetId() |
|||
} |
|||
); |
|||
|
|||
return exportRequestId; |
|||
} |
|||
``` |
|||
|
|||
This pattern scales much better than holding open a web request while doing CPU-heavy or IO-heavy work. |
|||
|
|||
## Retries, exceptions, and cancellation |
|||
|
|||
ABP and Hangfire both care about retries, but you should still design jobs carefully. |
|||
|
|||
### How ABP behaves |
|||
|
|||
With ABP background jobs: |
|||
|
|||
- Unhandled exceptions trigger retries |
|||
- Retry intervals increase over time |
|||
- Default implementation uses exponential backoff behavior |
|||
- Jobs may eventually time out or be marked abandoned depending on configuration |
|||
|
|||
### What this means for your code |
|||
|
|||
A job should be: |
|||
|
|||
- Idempotent whenever possible |
|||
- Safe to retry |
|||
- Explicit about transient vs permanent failures |
|||
|
|||
For example, sending the same payment capture twice is dangerous. Sending the same “your report is ready” notification twice is annoying but manageable. Design around the difference. |
|||
|
|||
### Cancellation handling |
|||
|
|||
If you use `ICancellationTokenProvider`, be deliberate. If cancellation means “try again later,” let the exception flow. If cancellation means “stop and do not retry,” return gracefully. |
|||
|
|||
Example: |
|||
|
|||
```csharp |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.BackgroundJobs; |
|||
using Volo.Abp.Threading; |
|||
|
|||
public class DataSyncJob : AsyncBackgroundJob<int> |
|||
{ |
|||
private readonly ICancellationTokenProvider _cancellationTokenProvider; |
|||
|
|||
public DataSyncJob(ICancellationTokenProvider cancellationTokenProvider) |
|||
{ |
|||
_cancellationTokenProvider = cancellationTokenProvider; |
|||
} |
|||
|
|||
public override async Task ExecuteAsync(int args) |
|||
{ |
|||
var cancellationToken = _cancellationTokenProvider.Token; |
|||
|
|||
cancellationToken.ThrowIfCancellationRequested(); |
|||
|
|||
await Task.Delay(500, cancellationToken); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### Practical guidance |
|||
|
|||
- Keep jobs short and composable |
|||
- Persist progress if the job is large |
|||
- Use domain/application services inside jobs instead of putting business logic directly into the job class |
|||
- Log enough context to diagnose retries and failures |
|||
|
|||
## Recurring jobs and periodic work |
|||
|
|||
Not every background task is a one-time job. |
|||
|
|||
There are two different patterns: |
|||
|
|||
- Background jobs: one-off, delayed, or fire-and-forget work |
|||
- Background workers: periodic or recurring work |
|||
|
|||
In ABP, recurring processing is usually modeled with background workers rather than standard background jobs. |
|||
|
|||
### When to use a worker instead of a job |
|||
|
|||
Use a worker when you need: |
|||
|
|||
- A scheduled cleanup task |
|||
- A recurring sync with another system |
|||
- Polling behavior |
|||
- A cron-like schedule |
|||
|
|||
### Hangfire-backed recurring worker |
|||
|
|||
With Hangfire integration, you can derive from `HangfireBackgroundWorkerBase` and provide a cron expression. |
|||
|
|||
```csharp |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.BackgroundWorkers.Hangfire; |
|||
|
|||
public class ExpiredSessionsCleanupWorker : HangfireBackgroundWorkerBase |
|||
{ |
|||
private readonly ISessionCleanupService _sessionCleanupService; |
|||
|
|||
public ExpiredSessionsCleanupWorker(ISessionCleanupService sessionCleanupService) |
|||
{ |
|||
_sessionCleanupService = sessionCleanupService; |
|||
|
|||
RecurringJobId = "expired-sessions-cleanup"; |
|||
CronExpression = "0 * * * *"; |
|||
} |
|||
|
|||
public override async Task DoWorkAsync() |
|||
{ |
|||
await _sessionCleanupService.CleanupAsync(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
A few details matter here: |
|||
|
|||
- `RecurringJobId` should be stable and unique. |
|||
- `CronExpression` controls the schedule. |
|||
- Hangfire recurring scheduling is minute-based in normal use, so do not expect second-level precision. |
|||
|
|||
### Background jobs vs background workers |
|||
|
|||
A simple rule: |
|||
|
|||
- If a user action creates work to do later, use a background job. |
|||
- If the system itself needs to run something on a schedule, use a background worker. |
|||
|
|||
|
|||
|
|||
 |
|||
|
|||
## Queue isolation and scaling across multiple instances |
|||
|
|||
Once you run more than one application instance, background processing becomes an architecture concern rather than a coding detail. |
|||
|
|||
### Shared storage is required |
|||
|
|||
If multiple nodes are going to process Hangfire jobs, they must share the same Hangfire storage. |
|||
|
|||
Typical setups include: |
|||
|
|||
- Multiple web instances + one shared SQL Server storage |
|||
- Web instances enqueueing jobs + dedicated worker instances processing them |
|||
- Separate deployment slots or services sharing the same Hangfire backend |
|||
|
|||
### Disabling execution on some nodes |
|||
|
|||
Sometimes you want your web app to enqueue jobs but not execute them. |
|||
|
|||
ABP supports this: |
|||
|
|||
```csharp |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Volo.Abp.BackgroundJobs; |
|||
|
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
Configure<AbpBackgroundJobOptions>(options => |
|||
{ |
|||
options.IsJobExecutionEnabled = false; |
|||
}); |
|||
} |
|||
``` |
|||
|
|||
This is useful when: |
|||
|
|||
- You run dedicated worker processes |
|||
- You want predictable resource allocation |
|||
- You do not want front-end nodes competing for background work |
|||
|
|||
### Queue prefixing in clustered environments |
|||
|
|||
If multiple applications share the same Hangfire storage, isolate queues intentionally. |
|||
|
|||
For Hangfire integration in ABP, use `AbpHangfireOptions.DefaultQueuePrefix` to avoid queue collisions between different applications or environments. |
|||
|
|||
That matters more than teams expect. Without isolation, staging and production can end up looking at the same queues if storage is misconfigured. |
|||
|
|||
### Queue routing |
|||
|
|||
Hangfire supports multiple queues, and ABP’s Hangfire integration can route jobs based on conventions or attributes. |
|||
|
|||
In some scenarios, you may want specific jobs to go to specific queues, for example: |
|||
|
|||
- `emails` |
|||
- `exports` |
|||
- `integration` |
|||
- `critical` |
|||
|
|||
This is especially helpful when one queue can become noisy and starve more important work. |
|||
|
|||
|
|||
|
|||
 |
|||
|
|||
## Securing the Hangfire dashboard |
|||
|
|||
The Hangfire dashboard is extremely useful, but it is also an operations surface. Do not expose it casually. |
|||
|
|||
ABP provides authorization support for the dashboard via `AbpHangfireAuthorizationFilter`. |
|||
|
|||
A typical setup is to: |
|||
|
|||
- Require authentication |
|||
- Restrict by permission or role |
|||
- Optionally consider tenant-specific access rules |
|||
|
|||
Example: |
|||
|
|||
```csharp |
|||
app.UseAbpHangfireDashboard("/hangfire", new DashboardOptions |
|||
{ |
|||
Authorization = new[] |
|||
{ |
|||
new AbpHangfireAuthorizationFilter(requiredPermissionName: "Administration.Hangfire") |
|||
} |
|||
}); |
|||
``` |
|||
|
|||
Even if your app is internal, treat the dashboard like an admin area: |
|||
|
|||
- Put it behind authorization |
|||
- Avoid exposing it publicly without network restrictions |
|||
- Audit who can retry or inspect jobs |
|||
|
|||
## Common pitfalls and behavior differences |
|||
|
|||
This is the part that usually saves the most time. |
|||
|
|||
### 1. Jobs still land in `AbpBackgroundJob` instead of Hangfire |
|||
|
|||
If Hangfire is not properly activated, ABP may continue using its native background job storage and you will see jobs in the `AbpBackgroundJob` table instead of Hangfire storage. |
|||
|
|||
Check these first: |
|||
|
|||
- The `Volo.Abp.BackgroundJobs.HangFire` package is installed |
|||
- `AbpBackgroundJobsHangfireModule` is added in `[DependsOn]` |
|||
- `AddHangfire(...)` is configured correctly |
|||
- The application starts with the expected module graph |
|||
|
|||
If any of those are missing, you may think you are using Hangfire while you are actually still on the default provider. |
|||
|
|||
### 2. Passing large or complex objects into jobs |
|||
|
|||
Keep job args small. Prefer identifiers over rich objects. |
|||
|
|||
Good: |
|||
|
|||
- `OrderId` |
|||
- `UserId` |
|||
- `ExportRequestId` |
|||
|
|||
Bad: |
|||
|
|||
- Full EF entities |
|||
- Large DTO graphs |
|||
- Objects with lazy-loading behavior or runtime-only state |
|||
|
|||
### 3. Non-idempotent job logic |
|||
|
|||
Retries will happen. If running the same job twice can corrupt data, redesign the workflow. |
|||
|
|||
Common fixes: |
|||
|
|||
- Add a processed flag |
|||
- Use unique constraints where appropriate |
|||
- Check prior execution status before applying side effects |
|||
- Make external calls with idempotency keys when supported |
|||
|
|||
### 4. Assuming recurring jobs run with exact timing |
|||
|
|||
Hangfire recurring jobs are cron-based and typically evaluated on minute boundaries. That is fine for most scheduled business work, but it is not a real-time scheduler. |
|||
|
|||
### 5. Ignoring queue isolation in multi-app environments |
|||
|
|||
If several apps share one Hangfire store, queue naming and prefixing must be explicit. Otherwise, one application can accidentally process another application's jobs. |
|||
|
|||
## When to use / When NOT to use ABP + Hangfire |
|||
|
|||
### Use ABP + Hangfire when |
|||
|
|||
- You want ABP-friendly job abstractions with a stronger execution backend |
|||
- You need operational visibility and retry inspection |
|||
- You run multiple instances or worker nodes |
|||
- You have recurring background tasks |
|||
- Your jobs are part of business-critical workflows |
|||
|
|||
### Do NOT use it when |
|||
|
|||
- The work must complete synchronously before responding to the user |
|||
- The task is so trivial that plain in-memory processing is enough |
|||
- You need event streaming rather than job scheduling |
|||
- You need ultra-low-latency real-time processing with very tight timing guarantees |
|||
|
|||
For many line-of-business systems, ABP + Hangfire hits a very practical middle ground: easy enough to implement, strong enough to operate. |
|||
|
|||
## A production-minded implementation checklist |
|||
|
|||
Before shipping, verify these points: |
|||
|
|||
- Jobs are enqueued through `IBackgroundJobManager` unless you explicitly need Hangfire-specific APIs |
|||
- Job arguments are small and serializable |
|||
- Job logic is retry-safe and preferably idempotent |
|||
- Hangfire storage is shared by all processing nodes |
|||
- Dashboard access is restricted |
|||
- Queue names or prefixes are isolated per app/environment |
|||
- Long-running jobs are split into manageable steps where possible |
|||
- You know which nodes execute jobs and which only enqueue them |
|||
|
|||
## TL;DR |
|||
|
|||
- ABP gives you a clean background job abstraction; Hangfire gives you the production-grade execution engine. |
|||
- Keep using `IBackgroundJobManager` for most jobs so your application code stays provider-independent. |
|||
- Use background jobs for one-off work and Hangfire-backed background workers for recurring tasks. |
|||
- In multi-instance deployments, shared storage, queue isolation, and dashboard security are not optional. |
|||
- If jobs still go to `AbpBackgroundJob`, your Hangfire integration is probably not fully activated. |
|||
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1007 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
@ -0,0 +1,41 @@ |
|||
# Deep Dive on ABP AI Agent: The Complete Series |
|||
|
|||
ABP Studio is a development platform built around ABP Framework. With the introduction of **ABP AI Coding Agent**, it became something more: a platform where an AI agent works inside the same environment you already use to build, run, monitor, and ship ABP solutions. |
|||
|
|||
 |
|||
|
|||
General-purpose AI coding tools are excellent for horizontal, file-shaped work. They read source files, edit them, and run shell commands. But ABP solutions are **system-shaped**, not just file-shaped. A typical ABP solution is split across multiple modules and layers with strict dependency rules, composed of many runnable units (HTTP services, gateways, identity servers, background workers, Docker containers), and built on a strong set of conventions: aggregate roots, repositories, application services, DTOs, permissions, localization, event bus, distributed cache, and background jobs. |
|||
|
|||
A generic agent has none of that vocabulary. It does not know what a module is, which project is the Domain layer, or that an `ApplicationService` should not depend on a `DbContext` directly. It cannot start your microservices, gateway, and auth server together. It cannot tell you that the latest edit caused a runtime exception in the Identity service, because it has no concept of a running application. |
|||
|
|||
ABP AI Coding Agent was built to close exactly that gap. The agent is born inside a platform that already understands modules, run profiles, builds, migrations, proxies, Docker containers, monitoring, and Git workflows, and it uses every one of them. |
|||
|
|||
We wrote a **nine-part deep dive series** to explain how each part of this system works, not as a product tour, but as a practical look at the decisions, controls, and architecture behind the experience. |
|||
|
|||
## The Series |
|||
|
|||
1. **[Agent, Plan and Ask Modes](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-1-agent-plan-and-ask-modes-62wteg9t)** — The three interaction modes that control how much action the agent is allowed to take: **Ask** for understanding (read-only), **Plan** for designing the approach before editing, and **Agent** for full implementation with builds, tools, and iteration. |
|||
|
|||
2. **[Supported AI Models + Usage Recommendations](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-2-supported-ai-models-in-abp-3krbc7yc)** — How ABP Studio separates models by role (main, research, browser, text processor, Git review) and why treating model selection as a practical decision based on the task leads to a better balance of capability, speed, and cost. |
|||
|
|||
3. **[Rules, Skills and Lessons](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-3-rules-skills-and-lessons-ai6kxubt)** — The three mechanisms that give the agent solution-specific memory: **Rules** (always-on conventions), **Skills** (on-demand procedures), and **Lessons** (corrections the agent records and carries forward). |
|||
|
|||
4. **[Integrated ABP Studio Tools](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-4-integrated-abp-studio-tools-be2xa2om)** — The tools that connect the agent to ABP Studio's runtime environment: monitoring (exceptions, logs, requests), applications, containers, tasks, and build actions, with a practical walkthrough showing the difference between debugging with and without tool access. |
|||
|
|||
5. **[MCP (Model Context Protocol)](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-5-mcp-model-context-protocol-trb9o4ev)** — How MCP extends the agent beyond the solution boundary to reach external systems like Prometheus, SEO analyzers, or documentation services, with per-tool enable/disable controls and stdio/HTTP server support. |
|||
|
|||
6. **[ABP Studio Git Integration](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-6-abp-studio-git-integration-09tr41ec)** — The full Git loop inside ABP Studio: branching, diffing, AI-generated commit messages, AI code review on staged changes, GitHub issue context for starting tasks, and pull request feedback for addressing reviewer comments. |
|||
|
|||
7. **[Scopes](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-7-scopes-tfqtkdzu)** — How AI Scopes restrict the agent's working area to specific modules, packages, or solution areas, reducing unrelated exploration, preventing accidental edits, and making diffs easier to review. |
|||
|
|||
8. **[Parallel Agent Execution](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-8-parallel-agent-execution-1o0cik6g)** — Running multiple agent sessions at the same time, each with its own mode, model, scope, and workflow, plus read-only research subagents that fan out inside a single session. |
|||
|
|||
9. **[Workflows](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-9-workflows-7jo1adb1)** — Repeatable before/after steps that wrap agent runs: start containers, build packages, add migrations, generate proxies, restart applications, and run validation tasks, so the agent focuses on the code change while the platform handles the deterministic parts. |
|||
|
|||
## The Bigger Picture |
|||
|
|||
Each article focuses on one feature, but the real value comes from how they work together. |
|||
|
|||
Modes decide how much action the agent takes. Models decide which brain handles the work. Rules, Skills, and Lessons shape what the agent knows. Tools and MCP extend what it can reach. Scopes define where it can work. Workflows define what happens around the work. Git Integration makes the result reviewable and recoverable. Parallel Execution lets multiple tasks move forward at the same time. |
|||
|
|||
That is the ABP AI Coding Agent experience: **not a single AI button, but a set of controls built into a platform that already understands how ABP solutions are developed, run, and maintained.** |
|||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 321 KiB |
@ -0,0 +1,66 @@ |
|||
using System; |
|||
using System.Linq; |
|||
using System.Security.Claims; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Security.Claims; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.AntiForgery; |
|||
|
|||
public class AbpAntiForgeryClaimsPrincipalNormalizer : IAbpAntiForgeryClaimsPrincipalNormalizer, ITransientDependency |
|||
{ |
|||
public const string UserIdClaimIssuer = "AbpAntiForgery"; |
|||
|
|||
protected virtual string NormalizedIssuer => UserIdClaimIssuer; |
|||
|
|||
public virtual ClaimsPrincipal Normalize(ClaimsPrincipal principal) |
|||
{ |
|||
var normalized = new ClaimsPrincipal(); |
|||
|
|||
foreach (var identity in principal.Identities) |
|||
{ |
|||
normalized.AddIdentity(NormalizeIdentity(identity)); |
|||
} |
|||
|
|||
return normalized; |
|||
} |
|||
|
|||
protected virtual ClaimsIdentity NormalizeIdentity(ClaimsIdentity identity) |
|||
{ |
|||
return new ClaimsIdentity( |
|||
identity.Claims.Select(NormalizeClaim), |
|||
identity.AuthenticationType, |
|||
identity.NameClaimType, |
|||
identity.RoleClaimType) |
|||
{ |
|||
Actor = identity.Actor, |
|||
BootstrapContext = identity.BootstrapContext, |
|||
Label = identity.Label |
|||
}; |
|||
} |
|||
|
|||
protected virtual Claim NormalizeClaim(Claim claim) |
|||
{ |
|||
var newClaim = new Claim( |
|||
claim.Type, |
|||
claim.Value, |
|||
claim.ValueType, |
|||
IsUserIdentifierClaim(claim.Type) ? NormalizedIssuer : claim.Issuer, |
|||
claim.OriginalIssuer); |
|||
|
|||
foreach (var property in claim.Properties) |
|||
{ |
|||
newClaim.Properties[property.Key] = property.Value; |
|||
} |
|||
|
|||
return newClaim; |
|||
} |
|||
|
|||
// The claim types DefaultClaimUidExtractor inspects, in priority order, to build the antiforgery user id.
|
|||
protected virtual bool IsUserIdentifierClaim(string claimType) |
|||
{ |
|||
return string.Equals(claimType, AbpClaimTypes.UserId, StringComparison.Ordinal) || |
|||
string.Equals(claimType, "sub", StringComparison.Ordinal) || |
|||
string.Equals(claimType, ClaimTypes.NameIdentifier, StringComparison.Ordinal) || |
|||
string.Equals(claimType, ClaimTypes.Upn, StringComparison.Ordinal); |
|||
} |
|||
} |
|||
@ -0,0 +1,101 @@ |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Antiforgery; |
|||
using Microsoft.AspNetCore.Http; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Microsoft.Extensions.Options; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.AntiForgery; |
|||
|
|||
// Wraps the framework IAntiforgery so the antiforgery token's per-user identifier is computed against a
|
|||
// normalized principal on every entry point (generation and validation, controllers and Razor Pages,
|
|||
// ABP and built-in filters, cookie and bearer). This keeps the same user consistent across schemes whose
|
|||
// user id claim carries a different issuer (e.g. "LOCAL AUTHORITY" for the Identity cookie vs. the token
|
|||
// authority for a validated JWT or an OIDC cookie).
|
|||
public class AbpAntiforgery : IAntiforgery |
|||
{ |
|||
protected IAntiforgery Inner { get; } |
|||
|
|||
protected AbpAntiForgeryOptions Options { get; } |
|||
|
|||
public AbpAntiforgery( |
|||
IAntiforgery inner, |
|||
IOptions<AbpAntiForgeryOptions> options) |
|||
{ |
|||
Inner = inner; |
|||
Options = options.Value; |
|||
} |
|||
|
|||
public virtual AntiforgeryTokenSet GetAndStoreTokens(HttpContext httpContext) |
|||
{ |
|||
return WithNormalizedUser(httpContext, () => Inner.GetAndStoreTokens(httpContext)); |
|||
} |
|||
|
|||
public virtual AntiforgeryTokenSet GetTokens(HttpContext httpContext) |
|||
{ |
|||
return WithNormalizedUser(httpContext, () => Inner.GetTokens(httpContext)); |
|||
} |
|||
|
|||
public virtual Task<bool> IsRequestValidAsync(HttpContext httpContext) |
|||
{ |
|||
return WithNormalizedUserAsync(httpContext, () => Inner.IsRequestValidAsync(httpContext)); |
|||
} |
|||
|
|||
public virtual Task ValidateRequestAsync(HttpContext httpContext) |
|||
{ |
|||
return WithNormalizedUserAsync(httpContext, async () => |
|||
{ |
|||
await Inner.ValidateRequestAsync(httpContext); |
|||
return true; |
|||
}); |
|||
} |
|||
|
|||
public virtual void SetCookieTokenAndHeader(HttpContext httpContext) |
|||
{ |
|||
WithNormalizedUser(httpContext, () => |
|||
{ |
|||
Inner.SetCookieTokenAndHeader(httpContext); |
|||
return true; |
|||
}); |
|||
} |
|||
|
|||
protected virtual T WithNormalizedUser<T>(HttpContext httpContext, Func<T> action) |
|||
{ |
|||
if (!Options.NormalizeUserIdClaimIssuer) |
|||
{ |
|||
return action(); |
|||
} |
|||
|
|||
var normalizer = httpContext.RequestServices.GetRequiredService<IAbpAntiForgeryClaimsPrincipalNormalizer>(); |
|||
var originalPrincipal = httpContext.User; |
|||
httpContext.User = normalizer.Normalize(originalPrincipal); |
|||
try |
|||
{ |
|||
return action(); |
|||
} |
|||
finally |
|||
{ |
|||
httpContext.User = originalPrincipal; |
|||
} |
|||
} |
|||
|
|||
protected virtual async Task<T> WithNormalizedUserAsync<T>(HttpContext httpContext, Func<Task<T>> action) |
|||
{ |
|||
if (!Options.NormalizeUserIdClaimIssuer) |
|||
{ |
|||
return await action(); |
|||
} |
|||
|
|||
var normalizer = httpContext.RequestServices.GetRequiredService<IAbpAntiForgeryClaimsPrincipalNormalizer>(); |
|||
var originalPrincipal = httpContext.User; |
|||
httpContext.User = normalizer.Normalize(originalPrincipal); |
|||
try |
|||
{ |
|||
return await action(); |
|||
} |
|||
finally |
|||
{ |
|||
httpContext.User = originalPrincipal; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
using System.Security.Claims; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.AntiForgery; |
|||
|
|||
public interface IAbpAntiForgeryClaimsPrincipalNormalizer |
|||
{ |
|||
// Returns a copy of the principal whose user identifier claims carry a stable issuer, so the
|
|||
// antiforgery token's per-user identifier is the same across authentication schemes.
|
|||
ClaimsPrincipal Normalize(ClaimsPrincipal principal); |
|||
} |
|||
@ -0,0 +1,263 @@ |
|||
using System; |
|||
using System.Security.Claims; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Antiforgery; |
|||
using Microsoft.AspNetCore.Http; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Shouldly; |
|||
using Volo.Abp.Security.Claims; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.AntiForgery; |
|||
|
|||
public class AbpAntiForgeryClaimsPrincipalNormalizer_Tests |
|||
{ |
|||
private const string CookieIssuer = "LOCAL AUTHORITY"; |
|||
private const string BearerIssuer = "https://localhost:44361/"; |
|||
private const string UserId = "3a0e6f1c-1111-2222-3333-444455556666"; |
|||
private const string AntiForgeryHeaderName = "RequestVerificationToken"; |
|||
private const string AntiForgeryCookieName = "AF"; |
|||
|
|||
[Fact] |
|||
public void Normalize_should_set_a_constant_issuer_on_user_identifier_claims_only() |
|||
{ |
|||
var usernameClaim = new Claim("preferred_username", "admin", ClaimValueTypes.String, CookieIssuer); |
|||
usernameClaim.Properties["test-property"] = "test-value"; |
|||
|
|||
var principal = new ClaimsPrincipal(new ClaimsIdentity( |
|||
new[] |
|||
{ |
|||
new Claim("sub", UserId, ClaimValueTypes.String, CookieIssuer), |
|||
new Claim(ClaimTypes.NameIdentifier, UserId, ClaimValueTypes.String, CookieIssuer), |
|||
usernameClaim |
|||
}, |
|||
"Identity.Application")); |
|||
|
|||
var normalized = new AbpAntiForgeryClaimsPrincipalNormalizer().Normalize(principal); |
|||
|
|||
normalized.FindFirst("sub")!.Issuer.ShouldBe(AbpAntiForgeryClaimsPrincipalNormalizer.UserIdClaimIssuer); |
|||
normalized.FindFirst(ClaimTypes.NameIdentifier)!.Issuer.ShouldBe(AbpAntiForgeryClaimsPrincipalNormalizer.UserIdClaimIssuer); |
|||
|
|||
// value and OriginalIssuer are kept; only Issuer changes
|
|||
normalized.FindFirst("sub")!.Value.ShouldBe(UserId); |
|||
normalized.FindFirst("sub")!.OriginalIssuer.ShouldBe(CookieIssuer); |
|||
|
|||
// non-identifier claims and their properties are untouched
|
|||
var normalizedUsername = normalized.FindFirst("preferred_username")!; |
|||
normalizedUsername.Issuer.ShouldBe(CookieIssuer); |
|||
normalizedUsername.Properties["test-property"].ShouldBe("test-value"); |
|||
|
|||
// the original principal is not mutated
|
|||
principal.FindFirst("sub")!.Issuer.ShouldBe(CookieIssuer); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Normalize_should_preserve_identity_metadata() |
|||
{ |
|||
var actor = new ClaimsIdentity(new[] { new Claim(AbpClaimTypes.UserId, "actor-id") }, "Actor"); |
|||
var principal = new ClaimsPrincipal(new ClaimsIdentity( |
|||
new[] { new Claim("sub", UserId, ClaimValueTypes.String, BearerIssuer) }, |
|||
"Identity.Application") |
|||
{ |
|||
Actor = actor, |
|||
BootstrapContext = "raw-token", |
|||
Label = "my-label" |
|||
}); |
|||
|
|||
var normalized = new AbpAntiForgeryClaimsPrincipalNormalizer().Normalize(principal); |
|||
var normalizedIdentity = (ClaimsIdentity)normalized.Identity!; |
|||
|
|||
// identity metadata that the antiforgery claim uid does not use is still preserved on the copy
|
|||
normalizedIdentity.Actor.ShouldBeSameAs(actor); |
|||
normalizedIdentity.BootstrapContext.ShouldBe("raw-token"); |
|||
normalizedIdentity.Label.ShouldBe("my-label"); |
|||
// and the user id claim issuer was still normalized
|
|||
normalized.FindFirst("sub")!.Issuer.ShouldBe(AbpAntiForgeryClaimsPrincipalNormalizer.UserIdClaimIssuer); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Token_should_validate_for_the_same_cookie_principal_through_the_decorator() |
|||
{ |
|||
// The common server-rendered case: a token generated and validated for the same cookie principal.
|
|||
// Guards that wrapping IAntiforgery does not break the basic flow every page POST relies on.
|
|||
var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true); |
|||
|
|||
var cookiePrincipal = CreatePrincipal("Identity.Application", CookieIssuer); |
|||
var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, cookiePrincipal); |
|||
|
|||
var isValid = await ValidateAsync(antiforgery, serviceProvider, cookiePrincipal, cookieToken, requestToken); |
|||
|
|||
isValid.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Token_issued_under_one_scheme_should_validate_under_another_when_normalization_is_enabled() |
|||
{ |
|||
var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true); |
|||
|
|||
var cookiePrincipal = CreatePrincipal("Identity.Application", CookieIssuer); |
|||
var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, cookiePrincipal); |
|||
|
|||
var bearerPrincipal = CreatePrincipal("AuthenticationTypes.Federation", BearerIssuer); |
|||
var isValid = await ValidateAsync(antiforgery, serviceProvider, bearerPrincipal, cookieToken, requestToken); |
|||
|
|||
isValid.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Token_issued_under_one_scheme_should_fail_under_another_when_normalization_is_disabled() |
|||
{ |
|||
var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: false); |
|||
|
|||
var cookiePrincipal = CreatePrincipal("Identity.Application", CookieIssuer); |
|||
var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, cookiePrincipal); |
|||
|
|||
var bearerPrincipal = CreatePrincipal("AuthenticationTypes.Federation", BearerIssuer); |
|||
var isValid = await ValidateAsync(antiforgery, serviceProvider, bearerPrincipal, cookieToken, requestToken); |
|||
|
|||
isValid.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Token_should_validate_when_the_cookie_principal_user_id_issuer_is_not_local_authority() |
|||
{ |
|||
// Tiered/OIDC templates back the cookie with an OIDC principal whose user id issuer is the token
|
|||
// authority. Because the decorator normalizes both generation and validation (Razor Pages validate
|
|||
// through the same decorated IAntiforgery), the per-user identifier still matches.
|
|||
var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true); |
|||
|
|||
var oidcCookiePrincipal = CreatePrincipal("Identity.Application", BearerIssuer); |
|||
var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, oidcCookiePrincipal); |
|||
|
|||
var isValid = await ValidateAsync(antiforgery, serviceProvider, oidcCookiePrincipal, cookieToken, requestToken); |
|||
|
|||
isValid.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Token_should_validate_across_schemes_when_principal_has_both_sub_and_name_identifier() |
|||
{ |
|||
// The extractor picks "sub" before NameIdentifier and a principal can carry both, so the
|
|||
// normalization must cover the claim actually picked.
|
|||
var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true); |
|||
|
|||
var cookiePrincipal = CreatePrincipalWithSubAndNameIdentifier("Identity.Application", CookieIssuer); |
|||
var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, cookiePrincipal); |
|||
|
|||
var bearerPrincipal = CreatePrincipalWithSubAndNameIdentifier("AuthenticationTypes.Federation", BearerIssuer); |
|||
var isValid = await ValidateAsync(antiforgery, serviceProvider, bearerPrincipal, cookieToken, requestToken); |
|||
|
|||
isValid.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Decorator_should_restore_the_original_principal_after_each_call() |
|||
{ |
|||
var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true); |
|||
|
|||
var originalPrincipal = CreatePrincipal("AuthenticationTypes.Federation", BearerIssuer); |
|||
var httpContext = new DefaultHttpContext { User = originalPrincipal, RequestServices = serviceProvider }; |
|||
|
|||
antiforgery.GetAndStoreTokens(httpContext); |
|||
|
|||
httpContext.User.ShouldBeSameAs(originalPrincipal); |
|||
httpContext.User.FindFirst(AbpClaimTypes.UserId)!.Issuer.ShouldBe(BearerIssuer); |
|||
|
|||
httpContext.Request.Headers["Cookie"] = $"{AntiForgeryCookieName}=invalid"; |
|||
await antiforgery.IsRequestValidAsync(httpContext); |
|||
|
|||
httpContext.User.ShouldBeSameAs(originalPrincipal); |
|||
httpContext.User.FindFirst(AbpClaimTypes.UserId)!.Issuer.ShouldBe(BearerIssuer); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Decorator_should_not_normalize_when_disabled() |
|||
{ |
|||
var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: false); |
|||
|
|||
var principal = CreatePrincipal("Identity.Application", CookieIssuer); |
|||
var httpContext = new DefaultHttpContext { User = principal, RequestServices = serviceProvider }; |
|||
|
|||
antiforgery.GetAndStoreTokens(httpContext); |
|||
httpContext.User.ShouldBeSameAs(principal); |
|||
|
|||
httpContext.Request.Headers["Cookie"] = $"{AntiForgeryCookieName}=invalid"; |
|||
await antiforgery.IsRequestValidAsync(httpContext); |
|||
httpContext.User.ShouldBeSameAs(principal); |
|||
} |
|||
|
|||
private static ClaimsPrincipal CreatePrincipal(string authenticationType, string userIdClaimIssuer) |
|||
{ |
|||
return new ClaimsPrincipal(new ClaimsIdentity( |
|||
new[] |
|||
{ |
|||
new Claim(AbpClaimTypes.UserId, UserId, ClaimValueTypes.String, userIdClaimIssuer), |
|||
new Claim("preferred_username", "admin", ClaimValueTypes.String, userIdClaimIssuer) |
|||
}, |
|||
authenticationType, |
|||
"preferred_username", |
|||
AbpClaimTypes.Role)); |
|||
} |
|||
|
|||
private static ClaimsPrincipal CreatePrincipalWithSubAndNameIdentifier(string authenticationType, string issuer) |
|||
{ |
|||
return new ClaimsPrincipal(new ClaimsIdentity( |
|||
new[] |
|||
{ |
|||
new Claim("sub", UserId, ClaimValueTypes.String, issuer), |
|||
new Claim(ClaimTypes.NameIdentifier, UserId, ClaimValueTypes.String, issuer), |
|||
new Claim("preferred_username", "admin", ClaimValueTypes.String, issuer) |
|||
}, |
|||
authenticationType, |
|||
"preferred_username", |
|||
AbpClaimTypes.Role)); |
|||
} |
|||
|
|||
private static (IAntiforgery antiforgery, IServiceProvider serviceProvider) CreateDecoratedAntiforgery(bool normalize) |
|||
{ |
|||
var services = new ServiceCollection(); |
|||
services.AddLogging(); |
|||
services.AddDataProtection(); |
|||
services.AddAntiforgery(options => |
|||
{ |
|||
options.Cookie.Name = AntiForgeryCookieName; |
|||
options.HeaderName = AntiForgeryHeaderName; |
|||
}); |
|||
services.AddTransient<IAbpAntiForgeryClaimsPrincipalNormalizer, AbpAntiForgeryClaimsPrincipalNormalizer>(); |
|||
|
|||
var serviceProvider = services.BuildServiceProvider(); |
|||
|
|||
var antiforgery = new AbpAntiforgery( |
|||
serviceProvider.GetRequiredService<IAntiforgery>(), |
|||
Microsoft.Extensions.Options.Options.Create(new AbpAntiForgeryOptions { NormalizeUserIdClaimIssuer = normalize })); |
|||
|
|||
return (antiforgery, serviceProvider); |
|||
} |
|||
|
|||
private static (string cookieToken, string requestToken) GenerateToken( |
|||
IAntiforgery antiforgery, IServiceProvider serviceProvider, ClaimsPrincipal user) |
|||
{ |
|||
var httpContext = new DefaultHttpContext { User = user, RequestServices = serviceProvider }; |
|||
var tokenSet = antiforgery.GetAndStoreTokens(httpContext); |
|||
return (ExtractCookieToken(httpContext), tokenSet.RequestToken!); |
|||
} |
|||
|
|||
private static async Task<bool> ValidateAsync( |
|||
IAntiforgery antiforgery, IServiceProvider serviceProvider, ClaimsPrincipal user, string cookieToken, string requestToken) |
|||
{ |
|||
var httpContext = new DefaultHttpContext { User = user, RequestServices = serviceProvider }; |
|||
httpContext.Request.Headers["Cookie"] = $"{AntiForgeryCookieName}={cookieToken}"; |
|||
httpContext.Request.Headers[AntiForgeryHeaderName] = requestToken; |
|||
|
|||
return await antiforgery.IsRequestValidAsync(httpContext); |
|||
} |
|||
|
|||
private static string ExtractCookieToken(HttpContext httpContext) |
|||
{ |
|||
var setCookie = httpContext.Response.Headers.SetCookie.ToString(); |
|||
var prefix = AntiForgeryCookieName + "="; |
|||
var start = setCookie.IndexOf(prefix, StringComparison.Ordinal) + prefix.Length; |
|||
var end = setCookie.IndexOf(';', start); |
|||
return end < 0 ? setCookie.Substring(start) : setCookie.Substring(start, end - start); |
|||
} |
|||
} |
|||
@ -0,0 +1,182 @@ |
|||
using System; |
|||
using System.Security.Claims; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Antiforgery; |
|||
using Microsoft.AspNetCore.Http; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Shouldly; |
|||
using Volo.Abp.Security.Claims; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.AntiForgery; |
|||
|
|||
public class AbpAntiforgery_Tests |
|||
{ |
|||
private const string BearerIssuer = "https://localhost:44361/"; |
|||
private const string UserId = "3a0e6f1c-1111-2222-3333-444455556666"; |
|||
|
|||
[Fact] |
|||
public Task GetAndStoreTokens_should_normalize_the_user_and_restore_it() => |
|||
Should_normalize_then_restore( |
|||
(antiforgery, httpContext) => { antiforgery.GetAndStoreTokens(httpContext); return Task.CompletedTask; }, |
|||
inner => inner.UserSeenByGetAndStoreTokens); |
|||
|
|||
[Fact] |
|||
public Task GetTokens_should_normalize_the_user_and_restore_it() => |
|||
Should_normalize_then_restore( |
|||
(antiforgery, httpContext) => { antiforgery.GetTokens(httpContext); return Task.CompletedTask; }, |
|||
inner => inner.UserSeenByGetTokens); |
|||
|
|||
[Fact] |
|||
public Task IsRequestValidAsync_should_normalize_the_user_and_restore_it() => |
|||
Should_normalize_then_restore( |
|||
(antiforgery, httpContext) => antiforgery.IsRequestValidAsync(httpContext), |
|||
inner => inner.UserSeenByIsRequestValid); |
|||
|
|||
[Fact] |
|||
public Task ValidateRequestAsync_should_normalize_the_user_and_restore_it() => |
|||
Should_normalize_then_restore( |
|||
(antiforgery, httpContext) => antiforgery.ValidateRequestAsync(httpContext), |
|||
inner => inner.UserSeenByValidateRequest); |
|||
|
|||
[Fact] |
|||
public Task SetCookieTokenAndHeader_should_normalize_the_user_and_restore_it() => |
|||
Should_normalize_then_restore( |
|||
(antiforgery, httpContext) => { antiforgery.SetCookieTokenAndHeader(httpContext); return Task.CompletedTask; }, |
|||
inner => inner.UserSeenBySetCookieTokenAndHeader); |
|||
|
|||
[Fact] |
|||
public void Should_delegate_the_result_to_the_inner_antiforgery() |
|||
{ |
|||
var inner = new RecordingAntiforgery(); |
|||
var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: true)); |
|||
var httpContext = CreateHttpContext(CreatePrincipal(BearerIssuer), withNormalizer: true); |
|||
|
|||
var tokenSet = antiforgery.GetAndStoreTokens(httpContext); |
|||
|
|||
tokenSet.RequestToken.ShouldBe(RecordingAntiforgery.RequestToken); |
|||
tokenSet.CookieToken.ShouldBe(RecordingAntiforgery.CookieToken); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_not_normalize_when_disabled() |
|||
{ |
|||
var inner = new RecordingAntiforgery(); |
|||
var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: false)); |
|||
var original = CreatePrincipal(BearerIssuer); |
|||
var httpContext = CreateHttpContext(original, withNormalizer: true); |
|||
|
|||
antiforgery.GetAndStoreTokens(httpContext); |
|||
|
|||
// the inner saw the original (un-normalized) principal
|
|||
inner.UserSeenByGetAndStoreTokens.ShouldBeSameAs(original); |
|||
inner.UserSeenByGetAndStoreTokens!.FindFirst(AbpClaimTypes.UserId)!.Issuer.ShouldBe(BearerIssuer); |
|||
httpContext.User.ShouldBeSameAs(original); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_not_resolve_the_normalizer_service_when_disabled() |
|||
{ |
|||
// the normalizer is intentionally not registered; the disabled fast-path must not touch RequestServices
|
|||
var inner = new RecordingAntiforgery(); |
|||
var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: false)); |
|||
var httpContext = CreateHttpContext(CreatePrincipal(BearerIssuer), withNormalizer: false); |
|||
|
|||
Should.NotThrow(() => antiforgery.GetAndStoreTokens(httpContext)); |
|||
} |
|||
|
|||
private static async Task Should_normalize_then_restore( |
|||
Func<IAntiforgery, HttpContext, Task> invoke, |
|||
Func<RecordingAntiforgery, ClaimsPrincipal?> userSeenByInner) |
|||
{ |
|||
var inner = new RecordingAntiforgery(); |
|||
var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: true)); |
|||
var original = CreatePrincipal(BearerIssuer); |
|||
var httpContext = CreateHttpContext(original, withNormalizer: true); |
|||
|
|||
await invoke(antiforgery, httpContext); |
|||
|
|||
// the inner ran against the normalized principal
|
|||
userSeenByInner(inner)!.FindFirst(AbpClaimTypes.UserId)!.Issuer |
|||
.ShouldBe(AbpAntiForgeryClaimsPrincipalNormalizer.UserIdClaimIssuer); |
|||
// the original principal is restored after the call
|
|||
httpContext.User.ShouldBeSameAs(original); |
|||
} |
|||
|
|||
private static Microsoft.Extensions.Options.IOptions<AbpAntiForgeryOptions> CreateOptions(bool normalize) |
|||
{ |
|||
return Microsoft.Extensions.Options.Options.Create( |
|||
new AbpAntiForgeryOptions { NormalizeUserIdClaimIssuer = normalize }); |
|||
} |
|||
|
|||
private static HttpContext CreateHttpContext(ClaimsPrincipal user, bool withNormalizer) |
|||
{ |
|||
var services = new ServiceCollection(); |
|||
if (withNormalizer) |
|||
{ |
|||
services.AddTransient<IAbpAntiForgeryClaimsPrincipalNormalizer, AbpAntiForgeryClaimsPrincipalNormalizer>(); |
|||
} |
|||
|
|||
return new DefaultHttpContext |
|||
{ |
|||
User = user, |
|||
RequestServices = services.BuildServiceProvider() |
|||
}; |
|||
} |
|||
|
|||
private static ClaimsPrincipal CreatePrincipal(string userIdClaimIssuer) |
|||
{ |
|||
return new ClaimsPrincipal(new ClaimsIdentity( |
|||
new[] |
|||
{ |
|||
new Claim(AbpClaimTypes.UserId, UserId, ClaimValueTypes.String, userIdClaimIssuer) |
|||
}, |
|||
"AuthenticationTypes.Federation")); |
|||
} |
|||
|
|||
private sealed class RecordingAntiforgery : IAntiforgery |
|||
{ |
|||
public const string RequestToken = "test-request-token"; |
|||
public const string CookieToken = "test-cookie-token"; |
|||
|
|||
public ClaimsPrincipal? UserSeenByGetAndStoreTokens { get; private set; } |
|||
public ClaimsPrincipal? UserSeenByGetTokens { get; private set; } |
|||
public ClaimsPrincipal? UserSeenByIsRequestValid { get; private set; } |
|||
public ClaimsPrincipal? UserSeenByValidateRequest { get; private set; } |
|||
public ClaimsPrincipal? UserSeenBySetCookieTokenAndHeader { get; private set; } |
|||
|
|||
public AntiforgeryTokenSet GetAndStoreTokens(HttpContext httpContext) |
|||
{ |
|||
UserSeenByGetAndStoreTokens = httpContext.User; |
|||
return CreateTokenSet(); |
|||
} |
|||
|
|||
public AntiforgeryTokenSet GetTokens(HttpContext httpContext) |
|||
{ |
|||
UserSeenByGetTokens = httpContext.User; |
|||
return CreateTokenSet(); |
|||
} |
|||
|
|||
public Task<bool> IsRequestValidAsync(HttpContext httpContext) |
|||
{ |
|||
UserSeenByIsRequestValid = httpContext.User; |
|||
return Task.FromResult(true); |
|||
} |
|||
|
|||
public Task ValidateRequestAsync(HttpContext httpContext) |
|||
{ |
|||
UserSeenByValidateRequest = httpContext.User; |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public void SetCookieTokenAndHeader(HttpContext httpContext) |
|||
{ |
|||
UserSeenBySetCookieTokenAndHeader = httpContext.User; |
|||
} |
|||
|
|||
private static AntiforgeryTokenSet CreateTokenSet() |
|||
{ |
|||
return new AntiforgeryTokenSet(RequestToken, CookieToken, "RequestVerificationToken", "RequestVerificationToken"); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
using System.Linq; |
|||
using Shouldly; |
|||
using Volo.Abp.AspNetCore.SignalR.SampleHubs; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.AspNetCore.SignalR; |
|||
|
|||
public class HubConfigList_Tests |
|||
{ |
|||
[Fact] |
|||
public void Should_Apply_ConfigAction_For_Generic_Overload() |
|||
{ |
|||
var list = new HubConfigList(); |
|||
|
|||
list.AddOrUpdate<RegularHub>(config => config.RoutePattern = "/custom-route"); |
|||
|
|||
var hubConfig = list.Single(c => c.HubType == typeof(RegularHub)); |
|||
hubConfig.RoutePattern.ShouldBe("/custom-route"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Update_Existing_Hub_For_Generic_Overload() |
|||
{ |
|||
var list = new HubConfigList(); |
|||
|
|||
list.AddOrUpdate<RegularHub>(); |
|||
list.AddOrUpdate<RegularHub>(config => config.RoutePattern = "/custom-route"); |
|||
|
|||
list.Count.ShouldBe(1); |
|||
list.Single(c => c.HubType == typeof(RegularHub)).RoutePattern.ShouldBe("/custom-route"); |
|||
} |
|||
} |
|||
@ -0,0 +1,275 @@ |
|||
#nullable enable |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Net.Http; |
|||
using Shouldly; |
|||
using Volo.Abp.Content; |
|||
using Volo.Abp.Http.Modeling; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Http.Client.ClientProxying; |
|||
|
|||
public class ClientProxyBase_GetAcceptForActionReturn_Tests |
|||
{ |
|||
[Fact] |
|||
public void IRemoteStreamContent_Should_Pick_OctetStream_Even_When_ContentTypes_Include_Json() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: typeof(IRemoteStreamContent).FullName!, |
|||
contentTypes: new[] { "application/json", "text/plain", "text/json" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void RemoteStreamContent_Concrete_Type_Should_Pick_OctetStream() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: typeof(RemoteStreamContent).FullName!, |
|||
contentTypes: null); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Json_In_ContentTypes_Should_Pick_Json() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/plain", "application/json", "text/json" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Only_Text_ContentTypes_Should_Pick_TextPlain() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/plain", "text/csv" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Empty_Or_Null_ContentTypes_Should_Return_Null() |
|||
{ |
|||
InvokeGetAcceptForActionReturn(BuildAction("System.Int32", null)).ShouldBeNull(); |
|||
InvokeGetAcceptForActionReturn(BuildAction("System.Int32", new string[0])).ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Mixed_Text_And_Octet_Stream_Should_Echo_First_Content_Type() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/plain", "application/octet-stream" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void JsonV2_Variant_Should_Still_Pick_Json() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "application/json; charset=utf-8" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Single_TextHtml_Should_Echo_Back_TextHtml() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/html" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("text/html"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void OctetStream_Only_With_ObjectReturn_Should_Echo_OctetStream() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "My.Project.UserDto", |
|||
contentTypes: new[] { "application/octet-stream" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void ApplicationXml_Only_Should_Echo_Back_Xml_Instead_Of_Legacy_Pair() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "My.Project.SoapEnvelope", |
|||
contentTypes: new[] { "application/xml" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/xml"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Case_Insensitive_Json_Match() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "APPLICATION/JSON" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Json_With_Charset_Parameter_Should_Still_Pick_Json() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "application/json; charset=utf-8" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Text_With_Charset_Parameter_Should_Still_Pick_TextPlain() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/plain ; charset=utf-8 " }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Text_Json_Should_Echo_Back_Text_Json() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/json" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("text/json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Application_Problem_Json_Should_Echo_Back_The_Plus_Json_Variant() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "application/problem+json" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/problem+json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Vendor_Plus_Json_Should_Echo_Back_The_Plus_Json_Variant() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "application/vnd.api+json" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/vnd.api+json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void IsRemoteStream_Flag_True_Should_Pick_OctetStream_Regardless_Of_TypeName() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "My.Project.CustomStream", |
|||
contentTypes: new[] { "application/json" }); |
|||
action.ReturnValue.IsRemoteStream = true; |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); |
|||
} |
|||
|
|||
private static string? InvokeGetAcceptForActionReturn(ActionApiDescriptionModel action) |
|||
{ |
|||
var proxy = new TestableClientProxy(); |
|||
return proxy.PublicGetAcceptForActionReturn(action); |
|||
} |
|||
|
|||
private static ActionApiDescriptionModel BuildAction(string returnType, IList<string>? contentTypes) |
|||
{ |
|||
return new ActionApiDescriptionModel |
|||
{ |
|||
UniqueName = "Sample", |
|||
Name = "Sample", |
|||
HttpMethod = "GET", |
|||
Url = "api/test", |
|||
SupportedVersions = new List<string>(), |
|||
ParametersOnMethod = new List<MethodParameterApiDescriptionModel>(), |
|||
Parameters = new List<ParameterApiDescriptionModel>(), |
|||
ReturnValue = new ReturnValueApiDescriptionModel |
|||
{ |
|||
Type = returnType, |
|||
TypeSimple = returnType, |
|||
ContentTypes = contentTypes |
|||
}, |
|||
AuthorizeDatas = new List<AuthorizeDataApiDescriptionModel>() |
|||
}; |
|||
} |
|||
|
|||
[Fact] |
|||
public void AddHeaders_With_ApiVersion_Should_Combine_OctetStream_Accept_With_Version_Suffix() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: typeof(IRemoteStreamContent).FullName!, |
|||
contentTypes: new[] { "application/json", "text/plain" }); |
|||
|
|||
var headers = InvokeAddHeadersAndCollectAccept(action, version: "2.0"); |
|||
|
|||
headers.ShouldContain("application/octet-stream; v=2.0"); |
|||
headers.ShouldNotContain(h => h == "text/plain; v=2.0"); |
|||
headers.ShouldNotContain(h => h == "application/json; v=2.0"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void AddHeaders_Without_ApiVersion_Should_Emit_OctetStream_For_Stream_Returns() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: typeof(IRemoteStreamContent).FullName!, |
|||
contentTypes: new[] { "application/json" }); |
|||
|
|||
var headers = InvokeAddHeadersAndCollectAccept(action, version: null); |
|||
|
|||
headers.ShouldContain("application/octet-stream"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void AddHeaders_Without_ContentType_Metadata_Should_Fall_Back_To_Text_And_Json_Pair() |
|||
{ |
|||
var action = BuildAction(returnType: "System.Int32", contentTypes: null); |
|||
|
|||
var headers = InvokeAddHeadersAndCollectAccept(action, version: "1.0"); |
|||
|
|||
headers.ShouldContain("text/plain; v=1.0"); |
|||
headers.ShouldContain("application/json; v=1.0"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void AddHeaders_Without_ApiVersion_And_Without_ContentType_Metadata_Should_Emit_Unversioned_Text_Json_Pair() |
|||
{ |
|||
var action = BuildAction(returnType: "System.Int32", contentTypes: null); |
|||
|
|||
var headers = InvokeAddHeadersAndCollectAccept(action, version: null); |
|||
|
|||
headers.ShouldContain("text/plain"); |
|||
headers.ShouldContain("application/json"); |
|||
headers.ShouldNotContain(h => h.Contains("; v=")); |
|||
} |
|||
|
|||
private static IList<string> InvokeAddHeadersAndCollectAccept(ActionApiDescriptionModel action, string? version) |
|||
{ |
|||
var proxy = new TestableClientProxy(); |
|||
var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/x"); |
|||
var apiVersion = new ApiVersionInfo("HeaderModelBinding", version ?? string.Empty); |
|||
proxy.PublicAddAcceptHeaders(action, message, apiVersion); |
|||
return message.Headers.Accept.Select(a => a.ToString()).ToList(); |
|||
} |
|||
|
|||
private sealed class TestableClientProxy : ClientProxyBase<object> |
|||
{ |
|||
public string? PublicGetAcceptForActionReturn(ActionApiDescriptionModel action) |
|||
=> GetAcceptForActionReturn(action); |
|||
|
|||
public void PublicAddAcceptHeaders(ActionApiDescriptionModel action, HttpRequestMessage requestMessage, ApiVersionInfo apiVersion) |
|||
=> AddAcceptHeaders(action, requestMessage, apiVersion); |
|||
} |
|||
} |
|||
@ -0,0 +1,804 @@ |
|||
#nullable enable |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.IO; |
|||
using System.Linq; |
|||
using System.Net.Http; |
|||
using System.Text; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Shouldly; |
|||
using Volo.Abp.Content; |
|||
using Volo.Abp.Http.Modeling; |
|||
using Volo.Abp.Http.ProxyScripting.Generators; |
|||
using Volo.Abp.Json; |
|||
using Volo.Abp.Timing; |
|||
using Xunit; |
|||
using MicrosoftOptions = Microsoft.Extensions.Options.Options; |
|||
|
|||
namespace Volo.Abp.Http.Client.ClientProxying; |
|||
|
|||
public class ClientProxyRequestPayloadBuilder_FormData_Tests |
|||
{ |
|||
private readonly ClientProxyRequestPayloadBuilder _builder; |
|||
private readonly IJsonSerializer _jsonSerializer = new StubJsonSerializer(); |
|||
private static readonly ApiVersionInfo NoApiVersion = new("Query", "1.0"); |
|||
|
|||
public ClientProxyRequestPayloadBuilder_FormData_Tests() |
|||
{ |
|||
var services = new ServiceCollection(); |
|||
var scopeFactory = services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>(); |
|||
var options = MicrosoftOptions.Create(new AbpHttpClientProxyingOptions()); |
|||
_builder = new ClientProxyRequestPayloadBuilder(scopeFactory, options, new TestClock()); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Direct_IRemoteStreamContent_Param_Should_Produce_Single_StreamContent_Part() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormFileParam(name: "file", nameOnMethod: "file"), |
|||
}); |
|||
var stream = MakeStream("hello-direct"); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["file"] = new RemoteStreamContent(stream, "demo.txt", "text/plain"), |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var multipart = content.ShouldBeOfType<MultipartFormDataContent>(); |
|||
var parts = multipart.ToList(); |
|||
parts.Count.ShouldBe(1); |
|||
parts[0].Headers.ContentType!.MediaType.ShouldBe("text/plain"); |
|||
(await parts[0].ReadAsStringAsync()).ShouldBe("hello-direct"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Dto_With_IRemoteStreamContent_Property_Should_Flatten_To_Name_Plus_File_Parts() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadDto |
|||
{ |
|||
Name = "Alice", |
|||
File = new RemoteStreamContent(MakeStream("hello-single"), "single.txt", "text/plain"), |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var multipart = content.ShouldBeOfType<MultipartFormDataContent>(); |
|||
var parts = multipart.ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Name", "Alice"); |
|||
await AssertStreamPart(parts[1], "File", "hello-single", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Dto_With_IEnumerable_IRemoteStreamContent_Should_Emit_One_Part_Per_Stream() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Label", nameOnMethod: "input"), |
|||
FormFileParam(name: "Files", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadFilesDto |
|||
{ |
|||
Label = "batch", |
|||
Files = new[] |
|||
{ |
|||
new RemoteStreamContent(MakeStream("a-content"), "a.txt", "text/plain"), |
|||
new RemoteStreamContent(MakeStream("b-content"), "b.txt", "text/csv"), |
|||
}, |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(3); |
|||
await AssertStringPart(parts[0], "Label", "batch"); |
|||
await AssertStreamPart(parts[1], "Files", "a-content", "text/plain"); |
|||
await AssertStreamPart(parts[2], "Files", "b-content", "text/csv"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Nested_Dto_With_Child_File_Path_Should_Be_Reflected_Via_Dotted_Name() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Outer", nameOnMethod: "input"), |
|||
FormFileParam(name: "Child.File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestNestedUploadDto |
|||
{ |
|||
Outer = "outerVal", |
|||
Child = new TestNestedChildDto |
|||
{ |
|||
File = new RemoteStreamContent(MakeStream("hello-nested"), "nested.txt", "text/plain"), |
|||
}, |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Outer", "outerVal"); |
|||
await AssertStreamPart(parts[1], "Child.File", "hello-nested", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Form_Only_Action_Without_FormFile_Should_Still_Produce_Multipart_With_String_Parts() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormParam(name: "Tag", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadDto { Name = "Alice", Tag = "T1" }, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Name", "Alice"); |
|||
await AssertStringPart(parts[1], "Tag", "T1"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Body_Binding_Wins_Over_Form_And_Returns_Json_StringContent() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
new ParameterApiDescriptionModel |
|||
{ |
|||
Name = "input", |
|||
NameOnMethod = "input", |
|||
Type = typeof(TestUploadDto).FullName!, |
|||
TypeSimple = "dto", |
|||
BindingSourceId = ParameterBindingSources.Body, |
|||
}, |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadDto { Name = "Alice" }, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var stringContent = content.ShouldBeOfType<StringContent>(); |
|||
stringContent.Headers.ContentType!.MediaType.ShouldBe("application/json"); |
|||
var body = await stringContent.ReadAsStringAsync(); |
|||
body.ShouldContain("\"Name\":\"Alice\""); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task No_Form_Or_Body_Params_Should_Return_Null_Content() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
new ParameterApiDescriptionModel |
|||
{ |
|||
Name = "id", |
|||
NameOnMethod = "id", |
|||
Type = "System.Int32", |
|||
TypeSimple = "int", |
|||
BindingSourceId = ParameterBindingSources.Path, |
|||
}, |
|||
}); |
|||
var args = new Dictionary<string, object?> { ["id"] = 42 }; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
content.ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task FormFile_With_Null_Value_Should_Be_Skipped_Not_Throw() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadDto { Name = "Alice", File = null }, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(1); |
|||
await AssertStringPart(parts[0], "Name", "Alice"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Three_Level_Nested_Dto_Should_Resolve_Outer_Inner_File_Via_Dotted_Path() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Outer", nameOnMethod: "input"), |
|||
FormFileParam(name: "Inner.Child.File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestThreeLevelDto |
|||
{ |
|||
Outer = "outerVal", |
|||
Inner = new TestThreeLevelMiddleDto |
|||
{ |
|||
Child = new TestNestedChildDto |
|||
{ |
|||
File = new RemoteStreamContent(MakeStream("hello-3-levels"), "deep.txt", "text/plain"), |
|||
}, |
|||
}, |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Outer", "outerVal"); |
|||
await AssertStreamPart(parts[1], "Inner.Child.File", "hello-3-levels", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task File_With_UTF8_FileName_Should_Survive_To_Content_Disposition_FileName_Star() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormFileParam(name: "file", nameOnMethod: "file"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["file"] = new RemoteStreamContent(MakeStream("hello-utf8"), "中文-文件名.txt", "text/plain"), |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var part = content.ShouldBeOfType<MultipartFormDataContent>().Single(); |
|||
var disposition = part.Headers.ContentDisposition; |
|||
disposition.ShouldNotBeNull(); |
|||
disposition!.FileNameStar.ShouldBe("中文-文件名.txt"); |
|||
(await part.ReadAsStringAsync()).ShouldBe("hello-utf8"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Dto_Treated_As_Body_When_Not_Registered_Should_Serialize_As_Json_With_File_Field_Embedded() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
new ParameterApiDescriptionModel |
|||
{ |
|||
Name = "input", |
|||
NameOnMethod = "input", |
|||
Type = typeof(TestUploadDto).FullName!, |
|||
TypeSimple = "dto", |
|||
BindingSourceId = ParameterBindingSources.Body, |
|||
}, |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadDto |
|||
{ |
|||
Name = "Alice", |
|||
File = new RemoteStreamContent(MakeStream("ignored"), "x.txt", "text/plain"), |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var stringContent = content.ShouldBeOfType<StringContent>(); |
|||
stringContent.Headers.ContentType!.MediaType.ShouldBe("application/json"); |
|||
var body = await stringContent.ReadAsStringAsync(); |
|||
body.ShouldContain("\"Name\":\"Alice\""); |
|||
body.ShouldContain("\"File\""); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Dto_With_Both_Stream_Property_And_Stream_Collection_Should_Emit_All_Parts() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Label", nameOnMethod: "input"), |
|||
FormFileParam(name: "Main", nameOnMethod: "input"), |
|||
FormFileParam(name: "Extras", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestMixedUploadDto |
|||
{ |
|||
Label = "combo", |
|||
Main = new RemoteStreamContent(MakeStream("main-body"), "main.txt", "text/plain"), |
|||
Extras = new[] |
|||
{ |
|||
new RemoteStreamContent(MakeStream("extra-a"), "ea.txt", "text/csv"), |
|||
new RemoteStreamContent(MakeStream("extra-b"), "eb.txt", "text/plain"), |
|||
}, |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(4); |
|||
await AssertStringPart(parts[0], "Label", "combo"); |
|||
await AssertStreamPart(parts[1], "Main", "main-body", "text/plain"); |
|||
await AssertStreamPart(parts[2], "Extras", "extra-a", "text/csv"); |
|||
await AssertStreamPart(parts[3], "Extras", "extra-b", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Two_Different_Upload_Actions_On_Same_Builder_Should_Not_Pollute_Each_Other() |
|||
{ |
|||
var actionA = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var actionB = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Label", nameOnMethod: "input"), |
|||
FormFileParam(name: "Files", nameOnMethod: "input"), |
|||
}); |
|||
|
|||
var argsA = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadDto { Name = "A", File = new RemoteStreamContent(MakeStream("aa"), "a.txt", "text/plain") }, |
|||
}; |
|||
var argsB = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadFilesDto |
|||
{ |
|||
Label = "B", |
|||
Files = new[] { new RemoteStreamContent(MakeStream("bb"), "b.txt", "text/csv") }, |
|||
}, |
|||
}; |
|||
|
|||
var contentA = (await InvokeAsync(actionA, argsA)).ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
var contentB = (await InvokeAsync(actionB, argsB)).ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
|
|||
await AssertStringPart(contentA[0], "Name", "A"); |
|||
await AssertStreamPart(contentA[1], "File", "aa", "text/plain"); |
|||
await AssertStringPart(contentB[0], "Label", "B"); |
|||
await AssertStreamPart(contentB[1], "Files", "bb", "text/csv"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Inherited_Dto_With_File_Property_On_Base_Class_Should_Resolve_Via_Reflection() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
FormParam(name: "ChildOnly", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestInheritedUploadDto |
|||
{ |
|||
Name = "inherited", |
|||
File = new RemoteStreamContent(MakeStream("from-base"), "base.txt", "text/plain"), |
|||
ChildOnly = "extra", |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(3); |
|||
await AssertStringPart(parts[0], "Name", "inherited"); |
|||
await AssertStreamPart(parts[1], "File", "from-base", "text/plain"); |
|||
await AssertStringPart(parts[2], "ChildOnly", "extra"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Record_Dto_With_File_Property_Should_Resolve_Via_Reflection() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestRecordUploadDto( |
|||
Name: "Diana", |
|||
File: new RemoteStreamContent(MakeStream("from-record"), "r.txt", "text/plain")), |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Name", "Diana"); |
|||
await AssertStreamPart(parts[1], "File", "from-record", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Dto_With_DateTime_Enum_And_Nullable_Struct_Fields_Should_Round_Trip_As_String_Parts() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "When", nameOnMethod: "input"), |
|||
FormParam(name: "Status", nameOnMethod: "input"), |
|||
FormParam(name: "Quantity", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestPrimitiveUploadDto |
|||
{ |
|||
When = new DateTime(2025, 6, 1, 12, 34, 56, DateTimeKind.Utc), |
|||
Status = TestStatus.Active, |
|||
Quantity = 7, |
|||
File = new RemoteStreamContent(MakeStream("primitives"), "p.txt", "text/plain"), |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(4); |
|||
(await parts[0].ReadAsStringAsync()).ShouldStartWith("2025-06-01T12:34:56"); |
|||
(await parts[1].ReadAsStringAsync()).ShouldBe("Active"); |
|||
(await parts[2].ReadAsStringAsync()).ShouldBe("7"); |
|||
await AssertStreamPart(parts[3], "File", "primitives", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Dto_With_Nullable_Struct_Field_Set_To_Null_Should_Skip_The_Part_Entirely() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Quantity", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestPrimitiveUploadDto |
|||
{ |
|||
Quantity = null, |
|||
File = new RemoteStreamContent(MakeStream("null-qty"), "n.txt", "text/plain"), |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(1); |
|||
await AssertStreamPart(parts[0], "File", "null-qty", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Polymorphic_Dto_With_Derived_Type_Should_Reflect_Properties_From_Concrete_Runtime_Type() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormParam(name: "ExtraField", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
TestUploadDtoBase polymorphic = new TestPolymorphicDerivedDto |
|||
{ |
|||
Name = "poly", |
|||
ExtraField = "derived-only", |
|||
File = new RemoteStreamContent(MakeStream("from-derived"), "d.txt", "text/plain"), |
|||
}; |
|||
var args = new Dictionary<string, object?> { ["input"] = polymorphic }; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(3); |
|||
await AssertStringPart(parts[0], "Name", "poly"); |
|||
await AssertStringPart(parts[1], "ExtraField", "derived-only"); |
|||
await AssertStreamPart(parts[2], "File", "from-derived", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Generic_Dto_Closed_Over_Concrete_Type_Should_Reflect_Open_Generic_Property() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Payload", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestGenericUploadDto<string> |
|||
{ |
|||
Payload = "closed-string", |
|||
File = new RemoteStreamContent(MakeStream("g.txt-body"), "g.txt", "text/plain"), |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Payload", "closed-string"); |
|||
await AssertStreamPart(parts[1], "File", "g.txt-body", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Generic_Dto_With_Integer_Payload_Should_Convert_Via_ConvertValueToString() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Payload", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestGenericUploadDto<int> |
|||
{ |
|||
Payload = 42, |
|||
File = new RemoteStreamContent(MakeStream("int-payload"), "i.txt", "text/plain"), |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Payload", "42"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Underlying_Caller_Stream_Is_Wrapped_Without_Buffering_So_Retry_Sees_Drained_Source() |
|||
{ |
|||
// Pin pass-through behaviour: HttpClient retry that re-reads `sourceStream`
|
|||
// observes an empty body (no internal buffering). Opting in to buffering
|
|||
// later would double-allocate for large uploads.
|
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormFileParam(name: "file", nameOnMethod: "file"), |
|||
}); |
|||
var sourceStream = MakeStream("retry-payload"); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["file"] = new RemoteStreamContent(sourceStream, "r.txt", "text/plain"), |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var part = content.ShouldBeOfType<MultipartFormDataContent>().Single(); |
|||
sourceStream.Position.ShouldBe(0); |
|||
|
|||
(await part.ReadAsStringAsync()).ShouldBe("retry-payload"); |
|||
|
|||
sourceStream.Position.ShouldBe(sourceStream.Length); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Action_With_No_Parameters_Should_Return_Null_Content_Without_Throwing() |
|||
{ |
|||
var action = BuildAction(parameters: Array.Empty<ParameterApiDescriptionModel>()); |
|||
var args = new Dictionary<string, object?>(); |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
content.ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Path_Plus_Form_Plus_FormFile_Should_Skip_Path_And_Emit_Multipart_Only() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
new ParameterApiDescriptionModel |
|||
{ |
|||
Name = "id", |
|||
NameOnMethod = "id", |
|||
Type = "System.Int32", |
|||
TypeSimple = "int", |
|||
BindingSourceId = ParameterBindingSources.Path, |
|||
}, |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["id"] = 7, |
|||
["input"] = new TestUploadDto |
|||
{ |
|||
Name = "Bob", |
|||
File = new RemoteStreamContent(MakeStream("path-mixed"), "p.txt", "text/plain"), |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Name", "Bob"); |
|||
await AssertStreamPart(parts[1], "File", "path-mixed", "text/plain"); |
|||
} |
|||
|
|||
private Task<HttpContent?> InvokeAsync(ActionApiDescriptionModel action, IReadOnlyDictionary<string, object?> args) |
|||
=> _builder.BuildContentAsync(action, args, _jsonSerializer, NoApiVersion); |
|||
|
|||
private static ActionApiDescriptionModel BuildAction(ParameterApiDescriptionModel[] parameters) |
|||
=> new() |
|||
{ |
|||
UniqueName = "TestAction", |
|||
Name = "TestAction", |
|||
HttpMethod = "POST", |
|||
Url = "api/test", |
|||
SupportedVersions = new List<string>(), |
|||
ParametersOnMethod = new List<MethodParameterApiDescriptionModel>(), |
|||
Parameters = parameters.ToList(), |
|||
ReturnValue = new ReturnValueApiDescriptionModel |
|||
{ |
|||
Type = "System.String", |
|||
TypeSimple = "string", |
|||
}, |
|||
AuthorizeDatas = new List<AuthorizeDataApiDescriptionModel>(), |
|||
}; |
|||
|
|||
private static ParameterApiDescriptionModel FormParam(string name, string nameOnMethod) |
|||
=> new() |
|||
{ |
|||
Name = name, |
|||
NameOnMethod = nameOnMethod, |
|||
Type = "System.String", |
|||
TypeSimple = "string", |
|||
BindingSourceId = ParameterBindingSources.Form, |
|||
}; |
|||
|
|||
private static ParameterApiDescriptionModel FormFileParam(string name, string nameOnMethod) |
|||
=> new() |
|||
{ |
|||
Name = name, |
|||
NameOnMethod = nameOnMethod, |
|||
Type = typeof(IRemoteStreamContent).FullName!, |
|||
TypeSimple = "stream", |
|||
BindingSourceId = ParameterBindingSources.FormFile, |
|||
}; |
|||
|
|||
private static MemoryStream MakeStream(string text) |
|||
{ |
|||
var ms = new MemoryStream(); |
|||
ms.Write(Encoding.UTF8.GetBytes(text)); |
|||
ms.Position = 0; |
|||
return ms; |
|||
} |
|||
|
|||
private static async Task AssertStringPart(HttpContent part, string expectedName, string expectedValue) |
|||
{ |
|||
var disposition = part.Headers.ContentDisposition; |
|||
disposition.ShouldNotBeNull(); |
|||
disposition!.Name!.Trim('"').ShouldBe(expectedName); |
|||
(await part.ReadAsStringAsync()).ShouldBe(expectedValue); |
|||
} |
|||
|
|||
private static async Task AssertStreamPart(HttpContent part, string expectedName, string expectedBody, string expectedContentType) |
|||
{ |
|||
var disposition = part.Headers.ContentDisposition; |
|||
disposition.ShouldNotBeNull(); |
|||
disposition!.Name!.Trim('"').ShouldBe(expectedName); |
|||
part.Headers.ContentType!.MediaType.ShouldBe(expectedContentType); |
|||
(await part.ReadAsStringAsync()).ShouldBe(expectedBody); |
|||
} |
|||
|
|||
private class TestUploadDto |
|||
{ |
|||
public string? Name { get; set; } |
|||
public string? Tag { get; set; } |
|||
public IRemoteStreamContent? File { get; set; } |
|||
} |
|||
|
|||
private class TestUploadFilesDto |
|||
{ |
|||
public string? Label { get; set; } |
|||
public IEnumerable<IRemoteStreamContent>? Files { get; set; } |
|||
} |
|||
|
|||
private class TestNestedUploadDto |
|||
{ |
|||
public string? Outer { get; set; } |
|||
public TestNestedChildDto? Child { get; set; } |
|||
} |
|||
|
|||
private class TestNestedChildDto |
|||
{ |
|||
public IRemoteStreamContent? File { get; set; } |
|||
} |
|||
|
|||
private class TestThreeLevelDto |
|||
{ |
|||
public string? Outer { get; set; } |
|||
public TestThreeLevelMiddleDto? Inner { get; set; } |
|||
} |
|||
|
|||
private class TestThreeLevelMiddleDto |
|||
{ |
|||
public TestNestedChildDto? Child { get; set; } |
|||
} |
|||
|
|||
private class TestMixedUploadDto |
|||
{ |
|||
public string? Label { get; set; } |
|||
public IRemoteStreamContent? Main { get; set; } |
|||
public IEnumerable<IRemoteStreamContent>? Extras { get; set; } |
|||
} |
|||
|
|||
private class TestUploadDtoBase |
|||
{ |
|||
public string? Name { get; set; } |
|||
public IRemoteStreamContent? File { get; set; } |
|||
} |
|||
|
|||
private class TestInheritedUploadDto : TestUploadDtoBase |
|||
{ |
|||
public string? ChildOnly { get; set; } |
|||
} |
|||
|
|||
private record TestRecordUploadDto(string Name, IRemoteStreamContent File); |
|||
|
|||
private class TestPrimitiveUploadDto |
|||
{ |
|||
public DateTime When { get; set; } |
|||
public TestStatus Status { get; set; } |
|||
public int? Quantity { get; set; } |
|||
public IRemoteStreamContent? File { get; set; } |
|||
} |
|||
|
|||
private enum TestStatus |
|||
{ |
|||
Pending = 0, |
|||
Active = 1, |
|||
Done = 2, |
|||
} |
|||
|
|||
private class TestPolymorphicDerivedDto : TestUploadDtoBase |
|||
{ |
|||
public string? ExtraField { get; set; } |
|||
} |
|||
|
|||
private class TestGenericUploadDto<T> |
|||
{ |
|||
public T? Payload { get; set; } |
|||
public IRemoteStreamContent? File { get; set; } |
|||
} |
|||
|
|||
private class StubJsonSerializer : IJsonSerializer |
|||
{ |
|||
public string Serialize(object obj, bool camelCase = true, bool indented = false) |
|||
=> System.Text.Json.JsonSerializer.Serialize(obj); |
|||
|
|||
public T Deserialize<T>(string jsonString, bool camelCase = true) |
|||
=> System.Text.Json.JsonSerializer.Deserialize<T>(jsonString)!; |
|||
|
|||
public object Deserialize(Type type, string jsonString, bool camelCase = true) |
|||
=> System.Text.Json.JsonSerializer.Deserialize(jsonString, type)!; |
|||
} |
|||
|
|||
private class TestClock : IClock |
|||
{ |
|||
public DateTime Now => DateTime.UtcNow; |
|||
public DateTimeKind Kind => DateTimeKind.Utc; |
|||
public bool SupportsMultipleTimezone => false; |
|||
public DateTime Normalize(DateTime dateTime) => dateTime; |
|||
public DateTime ConvertToUserTime(DateTime utcDateTime) => utcDateTime; |
|||
public DateTimeOffset ConvertToUserTime(DateTimeOffset dateTimeOffset) => dateTimeOffset; |
|||
public DateTime ConvertToUtc(DateTime dateTime) => dateTime; |
|||
} |
|||
} |
|||
@ -0,0 +1,84 @@ |
|||
using System.IO; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Shouldly; |
|||
using Volo.Abp.Http.Client; |
|||
using Volo.Abp.TestApp.Application; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Http.DynamicProxying; |
|||
|
|||
public class PeopleAppServiceClientProxy_ReturnContentTypes_Tests : AbpHttpClientTestBase |
|||
{ |
|||
private readonly IPeopleAppService _peopleAppService; |
|||
|
|||
public PeopleAppServiceClientProxy_ReturnContentTypes_Tests() |
|||
{ |
|||
_peopleAppService = ServiceProvider.GetRequiredService<IPeopleAppService>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task EchoStatusAsync_Should_Return_Plain_String_Without_Quotes() |
|||
{ |
|||
var status = await _peopleAppService.EchoStatusAsync(); |
|||
status.ShouldBe("Open"); |
|||
status.StartsWith("\"").ShouldBeFalse(); |
|||
status.EndsWith("\"").ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task EchoStatusWithProducesJsonAsync_Should_Return_Plain_String_Without_Quotes() |
|||
{ |
|||
var status = await _peopleAppService.EchoStatusWithProducesJsonAsync(); |
|||
status.ShouldBe("Open"); |
|||
status.StartsWith("\"").ShouldBeFalse(); |
|||
status.EndsWith("\"").ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetBinaryImageAsync_Should_Return_Real_Binary_Not_Json_Metadata() |
|||
{ |
|||
using var content = await _peopleAppService.GetBinaryImageAsync(); |
|||
using var ms = new MemoryStream(); |
|||
await content.GetStream().CopyToAsync(ms); |
|||
var bytes = ms.ToArray(); |
|||
|
|||
content.FileName.ShouldBe("tiny.png"); |
|||
content.ContentType.ShouldStartWith("image/png"); |
|||
bytes.Length.ShouldBeGreaterThan(8); |
|||
|
|||
bytes[0].ShouldBe((byte)0x89); |
|||
bytes[1].ShouldBe((byte)0x50); |
|||
bytes[2].ShouldBe((byte)0x4E); |
|||
bytes[3].ShouldBe((byte)0x47); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task ThrowFromStringAsync_Should_Surface_Server_Exception_To_Client() |
|||
{ |
|||
await Should.ThrowAsync<AbpRemoteCallException>( |
|||
() => _peopleAppService.ThrowFromStringAsync() |
|||
); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task DownloadAsync_Should_Still_Work() |
|||
{ |
|||
using var content = await _peopleAppService.DownloadAsync(); |
|||
using var reader = new StreamReader(content.GetStream()); |
|||
var text = await reader.ReadToEndAsync(); |
|||
text.ShouldBe("DownloadAsync"); |
|||
content.FileName.ShouldBe("download.rtf"); |
|||
content.ContentType.ShouldStartWith("application/rtf"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task UploadAsync_String_Return_Should_Stay_Unquoted() |
|||
{ |
|||
using var ms = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("hello")); |
|||
var result = await _peopleAppService.UploadAsync( |
|||
new Content.RemoteStreamContent(ms, "upload.txt", "text/plain")); |
|||
result.ShouldBe("hello:text/plain:upload.txt"); |
|||
result.StartsWith("\"").ShouldBeFalse(); |
|||
} |
|||
} |
|||
@ -0,0 +1,103 @@ |
|||
using System.IO; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Shouldly; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Http.DynamicProxying; |
|||
|
|||
public class RegularTestControllerClientProxy_ReturnContentTypes_Tests : AbpHttpClientTestBase |
|||
{ |
|||
private readonly IRegularTestController _controller; |
|||
|
|||
public RegularTestControllerClientProxy_ReturnContentTypes_Tests() |
|||
{ |
|||
_controller = ServiceProvider.GetRequiredService<IRegularTestController>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetPlainStringAsync_Should_Return_Unwrapped_String() |
|||
{ |
|||
var result = await _controller.GetPlainStringAsync(); |
|||
result.ShouldBe("Open"); |
|||
result.StartsWith("\"").ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetProducesJsonStringAsync_Should_Strip_Json_Quotes() |
|||
{ |
|||
var result = await _controller.GetProducesJsonStringAsync(); |
|||
result.ShouldBe("Open"); |
|||
result.StartsWith("\"").ShouldBeFalse(); |
|||
result.EndsWith("\"").ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetProducesTextStringAsync_Should_Return_Raw_Text_Body() |
|||
{ |
|||
var result = await _controller.GetProducesTextStringAsync(); |
|||
result.ShouldBe("Open"); |
|||
result.StartsWith("\"").ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetNullStringAsync_Should_Return_Default_Or_Empty() |
|||
{ |
|||
var result = await _controller.GetNullStringAsync(); |
|||
(result == null || result == string.Empty).ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetEmptyStringAsync_Should_Return_Empty() |
|||
{ |
|||
var result = await _controller.GetEmptyStringAsync(); |
|||
(result == null || result == string.Empty).ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetProducesJsonNullStringAsync_Should_Not_Return_Literal_Null() |
|||
{ |
|||
var result = await _controller.GetProducesJsonNullStringAsync(); |
|||
result.ShouldNotBe("null"); |
|||
(result == null || result == string.Empty).ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetEscapedStringAsync_Should_Decode_Escaped_Characters() |
|||
{ |
|||
var result = await _controller.GetEscapedStringAsync(); |
|||
result.ShouldBe("a\"b\\c\nd"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task DownloadIconAsync_Should_Return_Binary_Bytes() |
|||
{ |
|||
using var content = await _controller.DownloadIconAsync(); |
|||
using var ms = new MemoryStream(); |
|||
await content.GetStream().CopyToAsync(ms); |
|||
ms.ToArray().ShouldBe(System.Text.Encoding.UTF8.GetBytes("ICON-BYTES")); |
|||
content.FileName.ShouldBe("icon.bin"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetReferenceTypeObjectAsync_Should_Not_Be_Wrapped_As_RemoteStreamContent() |
|||
{ |
|||
var result = await _controller.GetReferenceTypeObjectAsync(); |
|||
result.ShouldNotBeNull(); |
|||
result.ShouldNotBeAssignableTo<Volo.Abp.Content.IRemoteStreamContent>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetByteArrayAsync_Should_Round_Trip_Bytes() |
|||
{ |
|||
var bytes = await _controller.GetByteArrayAsync(); |
|||
bytes.ShouldBe(new byte[] { 1, 2, 3, 4 }); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Existing_IncrementValueAsync_Regression_Should_Still_Work() |
|||
{ |
|||
var result = await _controller.IncrementValueAsync(41); |
|||
result.ShouldBe(42); |
|||
} |
|||
} |
|||
@ -0,0 +1,279 @@ |
|||
using System.Collections.Generic; |
|||
using System.IO; |
|||
using System.Threading.Tasks; |
|||
using Shouldly; |
|||
using Volo.Abp.Content; |
|||
using Volo.Abp.Http.Modeling; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Http.Modeling; |
|||
|
|||
public class ReturnValueApiDescriptionModel_Tests |
|||
{ |
|||
[Fact] |
|||
public void Create_Without_ContentTypes_Should_Leave_Property_Null() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(string)); |
|||
|
|||
model.ShouldNotBeNull(); |
|||
model.TypeSimple.ShouldBe("string"); |
|||
model.ContentTypes.ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Create_With_ContentTypes_Should_Populate_The_Property() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create( |
|||
typeof(string), |
|||
new[] { "application/json", "text/plain" }); |
|||
|
|||
model.ContentTypes.ShouldNotBeNull(); |
|||
model.ContentTypes!.ShouldBe(new[] { "application/json", "text/plain" }); |
|||
} |
|||
} |
|||
|
|||
public class ReturnValueApiDescriptionModel_IsRemoteStream_Tests |
|||
{ |
|||
[Fact] |
|||
public void Direct_IRemoteStreamContent_Should_Be_True() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(IRemoteStreamContent)); |
|||
model.IsRemoteStream.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Concrete_RemoteStreamContent_Should_Be_True() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(RemoteStreamContent)); |
|||
model.IsRemoteStream.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Custom_Subclass_Of_IRemoteStreamContent_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(MyCustomStreamContent)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Task_Of_IRemoteStreamContent_Should_Be_True_After_UnwrapTask() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(Task<IRemoteStreamContent>)); |
|||
model.IsRemoteStream.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Task_Of_Custom_Stream_Subclass_Should_Be_False_After_UnwrapTask() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(Task<MyCustomStreamContent>)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void IRemoteStreamContent_Array_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(IRemoteStreamContent[])); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Concrete_RemoteStreamContent_Array_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(RemoteStreamContent[])); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void List_Of_IRemoteStreamContent_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(List<IRemoteStreamContent>)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void IEnumerable_Of_IRemoteStreamContent_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(IEnumerable<IRemoteStreamContent>)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void IReadOnlyCollection_Of_IRemoteStreamContent_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(IReadOnlyCollection<IRemoteStreamContent>)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Plain_String_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(string)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Int_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(int)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Plain_Dto_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(PlainDto)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Dto_Containing_IRemoteStreamContent_Property_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(DtoWithStream)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Dto_Inheriting_From_Type_With_Stream_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(DtoInheritingStream)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Byte_Array_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(byte[])); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Dictionary_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(Dictionary<string, int>)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
private class MyCustomStreamContent : IRemoteStreamContent |
|||
{ |
|||
public string? FileName => null; |
|||
public string? ContentType => null; |
|||
public long? ContentLength => null; |
|||
public Stream GetStream() => Stream.Null; |
|||
public void Dispose() { } |
|||
} |
|||
|
|||
private class PlainDto |
|||
{ |
|||
public int Id { get; set; } |
|||
public string Name { get; set; } = string.Empty; |
|||
} |
|||
|
|||
private class DtoWithStream |
|||
{ |
|||
public string FileName { get; set; } = string.Empty; |
|||
public IRemoteStreamContent? File { get; set; } |
|||
} |
|||
|
|||
private class DtoInheritingStream : PlainDto |
|||
{ |
|||
public IRemoteStreamContent? Stream { get; set; } |
|||
} |
|||
} |
|||
|
|||
public class ReturnValueApiDescriptionModel_BackwardsCompat_Tests |
|||
{ |
|||
[Fact] |
|||
public void Deserializing_Json_Without_ContentTypes_Field_Should_Leave_It_Null() |
|||
{ |
|||
var json = """
|
|||
{ |
|||
"type": "System.String", |
|||
"typeSimple": "string" |
|||
} |
|||
""";
|
|||
|
|||
var model = System.Text.Json.JsonSerializer.Deserialize<ReturnValueApiDescriptionModel>( |
|||
json, |
|||
new System.Text.Json.JsonSerializerOptions |
|||
{ |
|||
PropertyNameCaseInsensitive = true |
|||
}); |
|||
|
|||
model.ShouldNotBeNull(); |
|||
model!.Type.ShouldBe("System.String"); |
|||
model.TypeSimple.ShouldBe("string"); |
|||
model.ContentTypes.ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Deserializing_Json_With_ContentTypes_Field_Should_Populate_It() |
|||
{ |
|||
var json = """
|
|||
{ |
|||
"type": "System.String", |
|||
"typeSimple": "string", |
|||
"contentTypes": ["application/json", "text/plain"] |
|||
} |
|||
""";
|
|||
|
|||
var model = System.Text.Json.JsonSerializer.Deserialize<ReturnValueApiDescriptionModel>( |
|||
json, |
|||
new System.Text.Json.JsonSerializerOptions |
|||
{ |
|||
PropertyNameCaseInsensitive = true |
|||
}); |
|||
|
|||
model!.ContentTypes.ShouldNotBeNull(); |
|||
model.ContentTypes!.ShouldBe(new[] { "application/json", "text/plain" }); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Serializing_With_Null_ContentTypes_Should_Emit_Null_Or_Omit() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(string)); |
|||
var json = System.Text.Json.JsonSerializer.Serialize(model); |
|||
|
|||
var deserialized = System.Text.Json.JsonSerializer.Deserialize<ReturnValueApiDescriptionModel>( |
|||
json, |
|||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); |
|||
deserialized!.ContentTypes.ShouldBeNull(); |
|||
} |
|||
} |
|||
|
|||
public class ActionApiDescriptionModel_Tests |
|||
{ |
|||
[Fact] |
|||
public void Create_Should_Propagate_ReturnValueContentTypes() |
|||
{ |
|||
var method = typeof(ActionApiDescriptionModel_Tests).GetMethod(nameof(SampleMethod))!; |
|||
var model = ActionApiDescriptionModel.Create( |
|||
uniqueName: "SampleMethod", |
|||
method: method, |
|||
url: "api/test/sample", |
|||
httpMethod: "GET", |
|||
supportedVersions: new[] { "1.0" }, |
|||
allowAnonymous: true, |
|||
authorizeDatas: null, |
|||
implementFrom: null, |
|||
returnValueContentTypes: new[] { "application/octet-stream" }); |
|||
|
|||
model.ReturnValue.ContentTypes.ShouldNotBeNull(); |
|||
model.ReturnValue.ContentTypes!.ShouldBe(new[] { "application/octet-stream" }); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Create_Without_ReturnValueContentTypes_Should_Leave_Null() |
|||
{ |
|||
var method = typeof(ActionApiDescriptionModel_Tests).GetMethod(nameof(SampleMethod))!; |
|||
var model = ActionApiDescriptionModel.Create( |
|||
uniqueName: "SampleMethod", |
|||
method: method, |
|||
url: "api/test/sample", |
|||
httpMethod: "GET", |
|||
supportedVersions: new[] { "1.0" }); |
|||
|
|||
model.ReturnValue.ContentTypes.ShouldBeNull(); |
|||
} |
|||
|
|||
public string SampleMethod() => string.Empty; |
|||
} |
|||
@ -0,0 +1,266 @@ |
|||
#nullable enable |
|||
using System.Collections.Generic; |
|||
using Microsoft.Extensions.Options; |
|||
using Shouldly; |
|||
using Volo.Abp.Http.Modeling; |
|||
using Volo.Abp.Http.ProxyScripting.Generators; |
|||
using Volo.Abp.Http.ProxyScripting.Generators.JQuery; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Http.ProxyScripting.Generators.JQuery; |
|||
|
|||
public class JQueryProxyScriptGenerator_ContentTypes_Tests |
|||
{ |
|||
private readonly JQueryProxyScriptGenerator _generator = new( |
|||
Microsoft.Extensions.Options.Options.Create(new DynamicJavaScriptProxyOptions())); |
|||
|
|||
[Fact] |
|||
public void Should_Emit_Json_DataType_And_Accept_When_ContentTypes_Contain_Json() |
|||
{ |
|||
var script = _generator.CreateScript(BuildAppModel( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/plain", "application/json", "text/json" })); |
|||
|
|||
script.ShouldContain("dataType: 'json'"); |
|||
script.ShouldContain("Accept: 'application/json'"); |
|||
script.ShouldNotContain("dataType: 'text'"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Emit_Text_DataType_And_Accept_When_ContentTypes_Only_Text() |
|||
{ |
|||
var script = _generator.CreateScript(BuildAppModel( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/plain", "text/csv" })); |
|||
|
|||
script.ShouldContain("dataType: 'text'"); |
|||
script.ShouldContain("Accept: 'text/plain'"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Fallback_To_Legacy_Text_When_Return_Is_String_And_ContentTypes_Null() |
|||
{ |
|||
var script = _generator.CreateScript(BuildAppModel( |
|||
returnType: "System.String", |
|||
contentTypes: null)); |
|||
|
|||
// Legacy behavior preserved: string return → dataType: 'text' (no Accept override)
|
|||
script.ShouldContain("dataType: 'text'"); |
|||
script.ShouldNotContain("Accept:"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Emit_No_DataType_For_Non_String_Return_Without_ContentTypes() |
|||
{ |
|||
var script = _generator.CreateScript(BuildAppModel( |
|||
returnType: "System.Int32", |
|||
contentTypes: null)); |
|||
|
|||
script.ShouldNotContain("dataType: 'text'"); |
|||
script.ShouldNotContain("dataType: 'json'"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Not_Emit_Json_DataType_When_Only_Binary_ContentTypes() |
|||
{ |
|||
var script = _generator.CreateScript(BuildAppModel( |
|||
returnType: "System.Byte[]", |
|||
contentTypes: new[] { "application/octet-stream", "image/png" })); |
|||
|
|||
// jQuery dataType doesn't have a clean "blob" — fall through to no override
|
|||
script.ShouldNotContain("dataType: 'json'"); |
|||
script.ShouldNotContain("dataType: 'text'"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Prefer_Json_When_Json_Present_Even_With_Other_Types() |
|||
{ |
|||
var script = _generator.CreateScript(BuildAppModel( |
|||
returnType: "My.Project.UserDto", |
|||
contentTypes: new[] { "application/xml", "application/json", "text/html" })); |
|||
|
|||
script.ShouldContain("dataType: 'json'"); |
|||
script.ShouldContain("Accept: 'application/json'"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Case_Insensitive_Json_Detection() |
|||
{ |
|||
var script = _generator.CreateScript(BuildAppModel( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "APPLICATION/JSON" })); |
|||
|
|||
script.ShouldContain("dataType: 'json'"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void IsRemoteStream_Should_Skip_DataType_Override_To_Avoid_JSON_Metadata_Regression() |
|||
{ |
|||
// IRemoteStreamContent: ABP advertises application/json in formatter list, but
|
|||
// forcing dataType:'json' here would make the server JSON-serialise the stream.
|
|||
var model = BuildAppModel( |
|||
returnType: "Volo.Abp.Content.IRemoteStreamContent", |
|||
contentTypes: new[] { "text/plain", "application/json", "text/json" }, |
|||
isRemoteStream: true); |
|||
|
|||
var script = _generator.CreateScript(model); |
|||
|
|||
script.ShouldNotContain("dataType: 'json'"); |
|||
script.ShouldNotContain("Accept: 'application/json'"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Multipart_Upload_Should_Emit_FormData_Body_With_ProcessData_And_ContentType_False() |
|||
{ |
|||
var script = _generator.CreateScript(BuildUploadModel( |
|||
uploadParameters: new[] |
|||
{ |
|||
("Name", "input", ParameterBindingSources.Form), |
|||
("File", "input", ParameterBindingSources.FormFile), |
|||
})); |
|||
|
|||
script.ShouldContain("data: input"); |
|||
script.ShouldContain("processData: false"); |
|||
script.ShouldContain("contentType: false"); |
|||
script.ShouldNotContain("contentType: 'application/x-www-form-urlencoded"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Multipart_Upload_Should_Use_NameOnMethod_As_Data_Variable() |
|||
{ |
|||
var script = _generator.CreateScript(BuildUploadModel( |
|||
uploadParameters: new[] |
|||
{ |
|||
("file", "file", ParameterBindingSources.FormFile), |
|||
})); |
|||
|
|||
script.ShouldContain("data: file"); |
|||
script.ShouldContain("processData: false"); |
|||
script.ShouldContain("contentType: false"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Plain_Form_Action_Without_FormFile_Should_Still_Emit_UrlEncoded_ContentType() |
|||
{ |
|||
var script = _generator.CreateScript(BuildUploadModel( |
|||
uploadParameters: new[] |
|||
{ |
|||
("Name", "input", ParameterBindingSources.Form), |
|||
})); |
|||
|
|||
script.ShouldContain("contentType: 'application/x-www-form-urlencoded; charset=UTF-8'"); |
|||
script.ShouldNotContain("processData: false"); |
|||
script.ShouldNotContain("contentType: false"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Multipart_Upload_Should_Skip_FormPostData_And_Body_Generation() |
|||
{ |
|||
var script = _generator.CreateScript(BuildUploadModel( |
|||
uploadParameters: new[] |
|||
{ |
|||
("Name", "input", ParameterBindingSources.Form), |
|||
("File", "input", ParameterBindingSources.FormFile), |
|||
})); |
|||
|
|||
script.ShouldNotContain("'Name=' + "); |
|||
script.ShouldNotContain("JSON.stringify"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Multiple_Direct_FormFile_Params_Should_Forward_Only_First_Var_Known_Limitation() |
|||
{ |
|||
var script = _generator.CreateScript(BuildUploadModel( |
|||
uploadParameters: new[] |
|||
{ |
|||
("file1", "file1", ParameterBindingSources.FormFile), |
|||
("file2", "file2", ParameterBindingSources.FormFile), |
|||
})); |
|||
|
|||
script.ShouldContain("data: file1"); |
|||
script.ShouldNotContain("data: file2"); |
|||
script.ShouldNotContain("$.merge(file1, file2)"); |
|||
} |
|||
|
|||
private static ApplicationApiDescriptionModel BuildAppModel(string returnType, IList<string>? contentTypes, bool isRemoteStream = false) |
|||
{ |
|||
var model = ApplicationApiDescriptionModel.Create(); |
|||
var module = model.GetOrAddModule("app", "Default"); |
|||
var controller = module.GetOrAddController( |
|||
name: "TestController", |
|||
groupName: null, |
|||
isRemoteService: true, |
|||
isIntegrationService: false, |
|||
apiVersion: null, |
|||
type: typeof(object)); |
|||
|
|||
var action = new ActionApiDescriptionModel |
|||
{ |
|||
UniqueName = "DoSomethingAsync", |
|||
Name = "DoSomethingAsync", |
|||
HttpMethod = "GET", |
|||
Url = "api/test/do-something", |
|||
SupportedVersions = new List<string>(), |
|||
ParametersOnMethod = new List<MethodParameterApiDescriptionModel>(), |
|||
Parameters = new List<ParameterApiDescriptionModel>(), |
|||
ReturnValue = new ReturnValueApiDescriptionModel |
|||
{ |
|||
Type = returnType, |
|||
TypeSimple = returnType, |
|||
ContentTypes = contentTypes, |
|||
IsRemoteStream = isRemoteStream, |
|||
}, |
|||
AuthorizeDatas = new List<AuthorizeDataApiDescriptionModel>(), |
|||
}; |
|||
controller.AddAction("DoSomethingAsync", action); |
|||
|
|||
return model; |
|||
} |
|||
|
|||
private static ApplicationApiDescriptionModel BuildUploadModel( |
|||
(string Name, string NameOnMethod, string BindingSourceId)[] uploadParameters) |
|||
{ |
|||
var model = ApplicationApiDescriptionModel.Create(); |
|||
var module = model.GetOrAddModule("app", "Default"); |
|||
var controller = module.GetOrAddController( |
|||
name: "TestController", |
|||
groupName: null, |
|||
isRemoteService: true, |
|||
isIntegrationService: false, |
|||
apiVersion: null, |
|||
type: typeof(object)); |
|||
|
|||
var parameters = new List<ParameterApiDescriptionModel>(); |
|||
foreach (var (name, nameOnMethod, binding) in uploadParameters) |
|||
{ |
|||
parameters.Add(new ParameterApiDescriptionModel |
|||
{ |
|||
Name = name, |
|||
NameOnMethod = nameOnMethod, |
|||
Type = "System.String", |
|||
TypeSimple = "string", |
|||
BindingSourceId = binding, |
|||
}); |
|||
} |
|||
|
|||
var action = new ActionApiDescriptionModel |
|||
{ |
|||
UniqueName = "UploadAsync", |
|||
Name = "UploadAsync", |
|||
HttpMethod = "POST", |
|||
Url = "api/test/upload", |
|||
SupportedVersions = new List<string>(), |
|||
ParametersOnMethod = new List<MethodParameterApiDescriptionModel>(), |
|||
Parameters = parameters, |
|||
ReturnValue = new ReturnValueApiDescriptionModel |
|||
{ |
|||
Type = "System.Void", |
|||
TypeSimple = "void", |
|||
}, |
|||
AuthorizeDatas = new List<AuthorizeDataApiDescriptionModel>(), |
|||
}; |
|||
controller.AddAction("UploadAsync", action); |
|||
|
|||
return model; |
|||
} |
|||
} |
|||
@ -0,0 +1,846 @@ |
|||
import { readFileSync } from 'fs'; |
|||
import { join } from 'path'; |
|||
import { describe, expect, test } from 'vitest'; |
|||
import { eBindingSourceId } from '../enums'; |
|||
import { Action } from '../models'; |
|||
import { |
|||
createActionToBodyMapper, |
|||
createActionToMethodMapper, |
|||
createActionToSignatureMapper, |
|||
} from '../utils/service'; |
|||
|
|||
const TEMPLATE_PATH = join( |
|||
__dirname, |
|||
'..', |
|||
'commands', |
|||
'api', |
|||
'files-service', |
|||
'proxy', |
|||
'__namespace@dir__', |
|||
'__name@kebab__.service.ts.template', |
|||
); |
|||
|
|||
function buildAction(overrides: Partial<Action>): Action { |
|||
return { |
|||
uniqueName: 'GetStatusAsync', |
|||
name: 'GetStatus', |
|||
httpMethod: 'GET', |
|||
url: 'api/app/test-service/status', |
|||
supportedVersions: [], |
|||
parametersOnMethod: [], |
|||
parameters: [], |
|||
returnValue: { type: 'System.String', typeSimple: 'string' }, |
|||
...overrides, |
|||
} as Action; |
|||
} |
|||
|
|||
describe('createActionToBodyMapper — string return value', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('without contentTypes falls back to text mode (legacy behavior)', () => { |
|||
const body = mapBody(buildAction({})); |
|||
|
|||
expect(body.responseType).toBe('string'); |
|||
expect(body.httpResponseType).toBe('text'); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
|
|||
test('with contentTypes containing application/json picks json + Accept: application/json', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['application/json', 'text/plain'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('application/json'); |
|||
}); |
|||
|
|||
test('with only text/* contentTypes picks text + Accept: text/plain', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['text/plain', 'text/csv'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('text'); |
|||
expect(body.acceptHeader).toBe('text/plain'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — IRemoteStreamContent return value', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('always picks blob + Accept: application/octet-stream regardless of contentTypes', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
expect(body.acceptHeader).toBe('application/octet-stream'); |
|||
expect(body.isBlobMethod()).toBe(true); |
|||
}); |
|||
|
|||
test('binary-only contentTypes picks blob and echoes back the actual binary media type', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.Byte[]', |
|||
typeSimple: 'byte[]', |
|||
contentTypes: ['application/pdf'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
expect(body.acceptHeader).toBe('application/pdf'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — other return values', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('object return without contentTypes has no httpResponseType / acceptHeader (defaults to json)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { type: 'My.Project.UserDto', typeSimple: 'My.Project.UserDto' }, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBeUndefined(); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
|
|||
test('void return has no httpResponseType / acceptHeader', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { type: 'System.Void', typeSimple: 'void' }, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBeUndefined(); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
|
|||
test('registers a query parameter via the binding source', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
parameters: [ |
|||
{ |
|||
nameOnMethod: 'id', |
|||
name: 'id', |
|||
jsonName: null, |
|||
type: 'System.Guid', |
|||
typeSimple: 'string', |
|||
isOptional: false, |
|||
defaultValue: null, |
|||
constraintTypes: null, |
|||
bindingSourceId: eBindingSourceId.Query, |
|||
descriptorName: '', |
|||
}, |
|||
], |
|||
}), |
|||
); |
|||
|
|||
expect(body.params).toEqual(['id']); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — IsRemoteStream backend flag', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('isRemoteStream=true forces blob even if Type is a custom subclass name', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'My.Project.CustomStreamContent', |
|||
typeSimple: 'My.Project.CustomStreamContent', |
|||
isRemoteStream: true, |
|||
contentTypes: ['text/plain', 'application/json'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
expect(body.acceptHeader).toBe('application/octet-stream'); |
|||
expect(body.isBlobMethod()).toBe(true); |
|||
}); |
|||
|
|||
test('[Volo.Abp.Content.IRemoteStreamContent] (real ABP square-bracket form) must NOT pick blob and degrades responseType to any[]', () => { |
|||
// ABP serialises collections as `[T]` (not `T[]`) — pin the on-the-wire shape.
|
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.Collections.Generic.IList<Volo.Abp.Content.IRemoteStreamContent>', |
|||
typeSimple: '[Volo.Abp.Content.IRemoteStreamContent]', |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBeUndefined(); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
expect(body.isBlobMethod()).toBe(false); |
|||
expect(body.responseType).toBe('any[]'); |
|||
}); |
|||
|
|||
test('IRemoteStreamContent[] array return must NOT pick blob (server falls back to JSON metadata)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'Volo.Abp.Content.IRemoteStreamContent[]', |
|||
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent[]', |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBeUndefined(); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
expect(body.isBlobMethod()).toBe(false); |
|||
expect(body.responseType).toBe('any[]'); |
|||
}); |
|||
|
|||
test('isRemoteStream=false with stream-content type-name still detected by type name (legacy)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
expect(body.acceptHeader).toBe('application/octet-stream'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — +json suffix detection', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('application/problem+json echoes back as Accept (decoder stays json)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['application/problem+json'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('application/problem+json'); |
|||
}); |
|||
|
|||
test('text/json echoes back as text/json (decoder stays json)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['text/json'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('text/json'); |
|||
}); |
|||
|
|||
test('application/vnd.api+json echoes back as Accept (decoder stays json)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['application/vnd.api+json'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('application/vnd.api+json'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — expanded binary whitelist', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test.each([ |
|||
['application/wasm'], |
|||
['font/woff2'], |
|||
['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], |
|||
['application/vnd.ms-excel'], |
|||
['application/vnd.oasis.opendocument.spreadsheet'], |
|||
['application/x-msdownload'], |
|||
['application/rtf'], |
|||
['application/x-rar-compressed'], |
|||
['application/x-bzip2'], |
|||
['application/x-iso9660-image'], |
|||
['application/java-archive'], |
|||
['application/epub+zip'], |
|||
['model/gltf-binary'], |
|||
])('"%s" picked as blob', mediaType => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.Byte[]', |
|||
typeSimple: 'byte[]', |
|||
contentTypes: [mediaType], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — contentTypes precedence and edge cases', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('isBlobMethod() type detection wins over json in contentTypes', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'Volo.Abp.Content.RemoteStreamContent', |
|||
typeSimple: 'Volo.Abp.Content.RemoteStreamContent', |
|||
contentTypes: ['application/json', 'text/plain'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
expect(body.acceptHeader).toBe('application/octet-stream'); |
|||
}); |
|||
|
|||
test('IRemoteStreamContent[] array falls through to defaults (server returns JSON metadata, not binary)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'Volo.Abp.Content.IRemoteStreamContent[]', |
|||
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent[]', |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBeUndefined(); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
|
|||
test('case-insensitive json detection (APPLICATION/JSON)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['APPLICATION/JSON'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('application/json'); |
|||
}); |
|||
|
|||
test('image/* contentTypes alone picks blob and echoes back the first image type', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.Byte[]', |
|||
typeSimple: 'byte[]', |
|||
contentTypes: ['image/png', 'image/jpeg'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
expect(body.acceptHeader).toBe('image/png'); |
|||
}); |
|||
|
|||
test('video/* and audio/* picked as blob', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.Byte[]', |
|||
typeSimple: 'byte[]', |
|||
contentTypes: ['video/mp4', 'audio/mpeg'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
}); |
|||
|
|||
test('application/pdf contentTypes picked as blob', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.Byte[]', |
|||
typeSimple: 'byte[]', |
|||
contentTypes: ['application/pdf'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
}); |
|||
|
|||
test('empty contentTypes falls through to legacy string→text behavior', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { type: 'System.String', typeSimple: 'string', contentTypes: [] }, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('text'); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
|
|||
test('mixed text/* and application/json picks json and echoes the first json-shaped media type', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['text/json', 'text/plain', 'application/json'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('text/json'); |
|||
}); |
|||
|
|||
test('contentTypes with json-suffix variants still picks json', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['application/json; charset=utf-8'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
}); |
|||
|
|||
test('non-string non-blob type with contentTypes containing json defaults appropriately', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'My.Project.UserDto', |
|||
typeSimple: 'My.Project.UserDto', |
|||
contentTypes: ['application/json'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('application/json'); |
|||
}); |
|||
|
|||
test('json contentType with charset parameter is normalized', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['application/json; charset=utf-8'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('application/json'); |
|||
}); |
|||
|
|||
test('text contentType with charset parameter is normalized', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['text/plain ; charset=utf-8 '], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('text'); |
|||
expect(body.acceptHeader).toBe('text/plain'); |
|||
}); |
|||
|
|||
test('mixed text/plain (with charset) and application/json picks json after normalize', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['text/plain; charset=utf-8', 'application/json; charset=utf-8'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
}); |
|||
|
|||
test('text/csv only (custom text format) picks text and echoes the content type back', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['text/csv'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('text'); |
|||
expect(body.acceptHeader).toBe('text/csv'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — backward compatibility', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('legacy api-definition without contentTypes works (string)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { type: 'System.String', typeSimple: 'string' } as Partial<Action>['returnValue'], |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('text'); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
|
|||
test('legacy api-definition without contentTypes works (IRemoteStreamContent)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
expect(body.acceptHeader).toBe('application/octet-stream'); |
|||
}); |
|||
|
|||
test('legacy api-definition without contentTypes works (object)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { type: 'My.Project.UserDto', typeSimple: 'My.Project.UserDto' }, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBeUndefined(); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
|
|||
test('void / no return preserves legacy behavior', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { type: 'System.Void', typeSimple: 'void' }, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBeUndefined(); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
}); |
|||
|
|||
describe('proxy service template emission', () => { |
|||
const template = readFileSync(TEMPLATE_PATH, 'utf8'); |
|||
|
|||
test('reads body.httpResponseType and body.acceptHeader from body', () => { |
|||
expect(template).toContain('body.httpResponseType'); |
|||
expect(template).toContain('body.acceptHeader'); |
|||
}); |
|||
|
|||
test('emits Accept header conditional', () => { |
|||
expect(template).toMatch(/headers:\s*\{\s*Accept:/); |
|||
}); |
|||
|
|||
test('emits responseType only for non-json httpResponseType', () => { |
|||
expect(template).toMatch(/httpResponseType\s*&&\s*httpResponseType\s*!==\s*'json'/); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — multipart FormData uploads', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('DTO with one IRemoteStreamContent property collapses to FormData body using the method arg name', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
httpMethod: 'POST', |
|||
parameters: [ |
|||
{ name: 'Name', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any, |
|||
{ name: 'File', nameOnMethod: 'input', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.body).toBe('input'); |
|||
expect(body.params).toEqual([]); |
|||
}); |
|||
|
|||
test('DTO with IEnumerable<IRemoteStreamContent> collapses to FormData body using the method arg name', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
httpMethod: 'POST', |
|||
parameters: [ |
|||
{ name: 'Label', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any, |
|||
{ name: 'Files', nameOnMethod: 'input', type: 'System.Collections.Generic.IEnumerable<Volo.Abp.Content.IRemoteStreamContent>', typeSimple: '[Volo.Abp.Content.IRemoteStreamContent]', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.body).toBe('input'); |
|||
expect(body.params).toEqual([]); |
|||
}); |
|||
|
|||
test('nested DTO with IRemoteStreamContent collapses to FormData body using the method arg name', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
httpMethod: 'POST', |
|||
parameters: [ |
|||
{ name: 'Outer', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any, |
|||
{ name: 'Child.File', nameOnMethod: 'input', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.body).toBe('input'); |
|||
expect(body.params).toEqual([]); |
|||
}); |
|||
|
|||
test('direct IRemoteStreamContent parameter uses its own method arg name as the FormData body', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
httpMethod: 'POST', |
|||
parameters: [ |
|||
{ name: 'file', nameOnMethod: 'file', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.body).toBe('file'); |
|||
}); |
|||
|
|||
test('Path + upload-DTO mix keeps the path parameter while binding the upload to FormData', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/upload/{id}', |
|||
parameters: [ |
|||
{ name: 'id', nameOnMethod: 'id', type: 'System.Int32', typeSimple: 'number', bindingSourceId: eBindingSourceId.Path } as any, |
|||
{ name: 'Name', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any, |
|||
{ name: 'File', nameOnMethod: 'input', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.body).toBe('input'); |
|||
expect(body.url).toBe("`/api/upload/${id}`"); |
|||
}); |
|||
|
|||
test('actions without FormFile parameters are not affected', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
httpMethod: 'POST', |
|||
parameters: [ |
|||
{ name: 'Name', nameOnMethod: 'name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Body } as any, |
|||
], |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.body).toBe('name'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — multipart upload params regression', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('AppService convention ModelBinding non-file field sharing the upload arg is dropped from query params', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/app/proxy-demo-test/upload-single', |
|||
parameters: [ |
|||
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, |
|||
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>), |
|||
); |
|||
expect(body.body).toBe('input'); |
|||
expect(body.params.join(',')).not.toContain('name'); |
|||
}); |
|||
|
|||
test('Path param on a separate method arg stays in URL even when sibling form fields share the upload arg', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/proxy-demo/media/upload-with-path/{id}', |
|||
parameters: [ |
|||
{ nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any, |
|||
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, |
|||
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>), |
|||
); |
|||
expect(body.body).toBe('input'); |
|||
expect(body.url).toContain('${id}'); |
|||
expect(body.params.join(',')).not.toContain('name'); |
|||
}); |
|||
|
|||
test('Multiple direct FormFile method args: body forwards only the first var (known limitation, mirrors jQuery)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/proxy-demo/media/upload-two-direct', |
|||
parameters: [ |
|||
{ nameOnMethod: 'file1', name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
{ nameOnMethod: 'file2', name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>), |
|||
); |
|||
expect(body.body).toBe('file1'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToSignatureMapper — multipart upload signature collapse', () => { |
|||
const mapSignature = createActionToSignatureMapper(); |
|||
|
|||
test('FormFile DTO method arg collapses to FormData type', () => { |
|||
const sig = mapSignature(buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/app/proxy-demo-test/upload-single', |
|||
parametersOnMethod: [ |
|||
{ name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, |
|||
], |
|||
parameters: [ |
|||
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, |
|||
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>)); |
|||
const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); |
|||
expect(types).toContain('input:FormData'); |
|||
expect(types[types.length - 1]).toBe('config:Partial<Rest.Config>'); |
|||
}); |
|||
|
|||
test('Path arg keeps its primitive type while upload arg becomes FormData', () => { |
|||
const sig = mapSignature(buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/proxy-demo/media/upload-with-path/{id}', |
|||
parametersOnMethod: [ |
|||
{ name: 'id', type: 'System.Int32', typeAsString: 'System.Int32', typeSimple: 'number', isOptional: false, defaultValue: null } as any, |
|||
{ name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, |
|||
], |
|||
parameters: [ |
|||
{ nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any, |
|||
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, |
|||
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>)); |
|||
const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); |
|||
expect(types).toEqual([ |
|||
'id:number', |
|||
'input:FormData', |
|||
'config:Partial<Rest.Config>', |
|||
]); |
|||
}); |
|||
|
|||
test('Multiple direct IRemoteStreamContent method args each become FormData independently', () => { |
|||
const sig = mapSignature(buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/proxy-demo/media/upload-two-direct', |
|||
parametersOnMethod: [ |
|||
{ name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeAsString: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', isOptional: false, defaultValue: null } as any, |
|||
{ name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeAsString: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', isOptional: false, defaultValue: null } as any, |
|||
], |
|||
parameters: [ |
|||
{ nameOnMethod: 'file1', name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
{ nameOnMethod: 'file2', name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>)); |
|||
const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); |
|||
expect(types).toEqual([ |
|||
'file1:FormData', |
|||
'file2:FormData', |
|||
'config:Partial<Rest.Config>', |
|||
]); |
|||
}); |
|||
|
|||
test('Non-upload action signature stays untouched (regression guard)', () => { |
|||
const sig = mapSignature(buildAction({ |
|||
httpMethod: 'GET', |
|||
url: 'api/app/proxy-demo-test/get-item-by-id', |
|||
parametersOnMethod: [ |
|||
{ name: 'id', type: 'System.Int32', typeAsString: 'System.Int32', typeSimple: 'number', isOptional: false, defaultValue: null } as any, |
|||
], |
|||
parameters: [ |
|||
{ nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any, |
|||
], |
|||
} as Partial<Action>)); |
|||
const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); |
|||
expect(types).toEqual(['id:number', 'config:Partial<Rest.Config>']); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToMethodMapper — signature + body wired together for upload actions', () => { |
|||
const mapMethod = createActionToMethodMapper(); |
|||
|
|||
test('Upload action produces both FormData signature and FormData body in one pass', () => { |
|||
const method = mapMethod(buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/app/proxy-demo-test/upload-single', |
|||
parametersOnMethod: [ |
|||
{ name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, |
|||
], |
|||
parameters: [ |
|||
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, |
|||
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>)); |
|||
const sigTypes = method.signature.parameters.map((p: any) => `${p.name}:${p.type}`); |
|||
expect(sigTypes).toContain('input:FormData'); |
|||
expect(method.body.body).toBe('input'); |
|||
expect(method.body.params.join(',')).not.toContain('name'); |
|||
}); |
|||
|
|||
test('Query + ModelBinding + FormFile mix preserves Query as URL param and drops upload-arg fields', () => { |
|||
const method = mapMethod(buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/proxy-demo/media/upload-with-query', |
|||
parametersOnMethod: [ |
|||
{ name: 'tag', type: 'System.String', typeAsString: 'System.String', typeSimple: 'string', isOptional: false, defaultValue: null } as any, |
|||
{ name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, |
|||
], |
|||
parameters: [ |
|||
{ nameOnMethod: 'tag', name: 'tag', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Query } as any, |
|||
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, |
|||
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>)); |
|||
const sigTypes = method.signature.parameters.map((p: any) => `${p.name}:${p.type}`); |
|||
expect(sigTypes).toEqual(['tag:string', 'input:FormData', 'config:Partial<Rest.Config>']); |
|||
expect(method.body.body).toBe('input'); |
|||
expect(method.body.params).toContain('tag'); |
|||
expect(method.body.params.join(',')).not.toContain('name'); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,495 @@ |
|||
import { readFileSync } from 'fs'; |
|||
import { join } from 'path'; |
|||
import { template as lodashTemplate } from 'lodash'; |
|||
import { describe, expect, test } from 'vitest'; |
|||
|
|||
/** |
|||
* Smoke test that actually renders the proxy `.service.ts.template` against |
|||
* representative body configurations and asserts the emitted code matches |
|||
* what the runtime contract requires. |
|||
* |
|||
* This catches template-syntax / control-flow regressions that a string |
|||
* `toContain` check on the template source would silently let through. |
|||
*/ |
|||
|
|||
const TEMPLATE_PATH = join( |
|||
__dirname, |
|||
'..', |
|||
'commands', |
|||
'api', |
|||
'files-service', |
|||
'proxy', |
|||
'__namespace@dir__', |
|||
'__name@kebab__.service.ts.template', |
|||
); |
|||
|
|||
const TEMPLATE_SRC = readFileSync(TEMPLATE_PATH, 'utf8'); |
|||
|
|||
function render(context: Record<string, unknown>): string { |
|||
const compiled = lodashTemplate(TEMPLATE_SRC, { |
|||
imports: { |
|||
camel: (s: string) => s.charAt(0).toLowerCase() + s.slice(1), |
|||
serializeParameters: (params: Array<{ name: string; type: string; default?: string }>) => |
|||
params.map(p => `${p.name}: ${p.type}`).join(', '), |
|||
}, |
|||
}); |
|||
return compiled(context); |
|||
} |
|||
|
|||
function buildContext(body: Partial<MockBody>) { |
|||
return { |
|||
apiName: 'Default', |
|||
name: 'Sample', |
|||
namespace: 'app', |
|||
imports: [ |
|||
{ keyword: 'import', specifiers: ['RestService', 'Rest'], path: '@abp/ng.core' }, |
|||
{ keyword: 'import', specifiers: ['Injectable', 'inject'], path: '@angular/core' }, |
|||
], |
|||
methods: [ |
|||
{ |
|||
body: makeBody(body), |
|||
signature: { |
|||
name: 'GetSampleAsync', |
|||
parameters: [], |
|||
}, |
|||
}, |
|||
], |
|||
}; |
|||
} |
|||
|
|||
interface MockBody { |
|||
method: string; |
|||
url: string; |
|||
responseType: string; |
|||
responseTypeWithNamespace: string; |
|||
httpResponseType?: string; |
|||
acceptHeader?: string; |
|||
body?: string; |
|||
params: string[]; |
|||
dictParamVar?: string; |
|||
requestType: string; |
|||
isBlobMethod(): boolean; |
|||
} |
|||
|
|||
function makeBody(overrides: Partial<MockBody>): MockBody { |
|||
return { |
|||
method: 'GET', |
|||
url: "'/api/sample'", |
|||
responseType: 'any', |
|||
responseTypeWithNamespace: 'any', |
|||
httpResponseType: undefined, |
|||
acceptHeader: undefined, |
|||
body: undefined, |
|||
params: [], |
|||
dictParamVar: undefined, |
|||
requestType: 'any', |
|||
isBlobMethod: () => false, |
|||
...overrides, |
|||
}; |
|||
} |
|||
|
|||
describe('proxy service template — rendered output', () => { |
|||
test('default JSON body emits no responseType and no Accept header', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'MyDto', |
|||
responseTypeWithNamespace: 'My.Project.MyDto', |
|||
})); |
|||
|
|||
expect(output).toContain("method: 'GET'"); |
|||
expect(output).not.toContain('responseType:'); |
|||
expect(output).not.toContain('headers:'); |
|||
}); |
|||
|
|||
test('json httpResponseType emits Accept but no responseType (default is json)', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'string', |
|||
responseTypeWithNamespace: 'string', |
|||
httpResponseType: 'json', |
|||
acceptHeader: 'application/json', |
|||
})); |
|||
|
|||
expect(output).toContain("headers: { Accept: 'application/json' }"); |
|||
expect(output).not.toContain('responseType:'); |
|||
}); |
|||
|
|||
test('text httpResponseType emits both responseType and Accept', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'string', |
|||
responseTypeWithNamespace: 'string', |
|||
httpResponseType: 'text', |
|||
acceptHeader: 'text/plain', |
|||
})); |
|||
|
|||
expect(output).toContain("responseType: 'text'"); |
|||
expect(output).toContain("headers: { Accept: 'text/plain' }"); |
|||
}); |
|||
|
|||
test('blob (IRemoteStreamContent) emits Blob return type + responseType + Accept', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
responseTypeWithNamespace: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
isBlobMethod: () => true, |
|||
httpResponseType: 'blob', |
|||
acceptHeader: 'application/octet-stream', |
|||
})); |
|||
|
|||
expect(output).toContain("responseType: 'blob'"); |
|||
expect(output).toContain('Blob>'); |
|||
expect(output).toContain("headers: { Accept: 'application/octet-stream' }"); |
|||
}); |
|||
|
|||
test('IRemoteStreamContent[] degradation renders any[] return type and does not reference IRemoteStreamContent', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'any[]', |
|||
responseTypeWithNamespace: '[Volo.Abp.Content.IRemoteStreamContent]', |
|||
isBlobMethod: () => false, |
|||
httpResponseType: undefined, |
|||
acceptHeader: undefined, |
|||
})); |
|||
|
|||
expect(output).toContain('any[]'); |
|||
expect(output).not.toContain('IRemoteStreamContent'); |
|||
expect(output).not.toContain("responseType: 'blob'"); |
|||
}); |
|||
|
|||
test('arraybuffer httpResponseType emits responseType', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'ArrayBuffer', |
|||
responseTypeWithNamespace: 'ArrayBuffer', |
|||
httpResponseType: 'arraybuffer', |
|||
acceptHeader: 'application/octet-stream', |
|||
})); |
|||
|
|||
expect(output).toContain("responseType: 'arraybuffer'"); |
|||
expect(output).toContain("headers: { Accept: 'application/octet-stream' }"); |
|||
}); |
|||
|
|||
test('no acceptHeader → no headers line', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'string', |
|||
responseTypeWithNamespace: 'string', |
|||
httpResponseType: 'text', |
|||
acceptHeader: undefined, |
|||
})); |
|||
|
|||
expect(output).toContain("responseType: 'text'"); |
|||
expect(output).not.toContain('headers:'); |
|||
}); |
|||
|
|||
test('rendered service code is valid TypeScript-shaped (closing braces / semicolons)', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'string', |
|||
responseTypeWithNamespace: 'string', |
|||
httpResponseType: 'json', |
|||
acceptHeader: 'application/json', |
|||
})); |
|||
|
|||
expect(output).toContain('@Injectable({'); |
|||
expect(output).toContain('providedIn: \'root\''); |
|||
expect(output).toContain('export class SampleService'); |
|||
expect(output).toContain('this.restService.request<any,'); |
|||
expect(output.match(/}/g)!.length).toBeGreaterThanOrEqual(3); |
|||
}); |
|||
|
|||
test.each([ |
|||
{ name: 'string + json Accept', body: { responseType: 'string', responseTypeWithNamespace: 'string', httpResponseType: 'json', acceptHeader: 'application/problem+json' } }, |
|||
{ name: 'string + text Accept', body: { responseType: 'string', responseTypeWithNamespace: 'string', httpResponseType: 'text', acceptHeader: 'text/csv' } }, |
|||
{ name: 'blob + pdf Accept', body: { responseType: 'Blob', responseTypeWithNamespace: 'Blob', httpResponseType: 'blob', acceptHeader: 'application/pdf', isBlobMethod: () => true } }, |
|||
{ name: 'any[] degradation', body: { responseType: 'any[]', responseTypeWithNamespace: '[Volo.Abp.Content.IRemoteStreamContent]' } }, |
|||
{ name: 'xml Accept only', body: { responseType: 'any', responseTypeWithNamespace: 'any', acceptHeader: 'application/xml' } }, |
|||
])('rendered service compiles cleanly under real ts.Program ($name)', ({ body }) => { |
|||
const ts = require('typescript'); |
|||
const ctx = buildContext(body as Partial<MockBody>); |
|||
ctx.methods[0].signature.parameters = [{ name: 'config', type: 'Record<string, any>' } as any]; |
|||
const output = render(ctx); |
|||
|
|||
const abpStub = ` |
|||
declare module '@abp/ng.core' { |
|||
export namespace Rest { |
|||
export interface Config { |
|||
apiName?: string; |
|||
observe?: any; |
|||
skipHandleError?: boolean; |
|||
responseType?: string; |
|||
[key: string]: any; |
|||
} |
|||
export type Observe = any; |
|||
} |
|||
export class RestService { |
|||
request<TBody, TResponse>(req: any, config?: any): import('rxjs').Observable<TResponse>; |
|||
} |
|||
} |
|||
`;
|
|||
const domStub = 'declare class Blob { constructor(parts?: any[], options?: any); }\n'; |
|||
const angularCoreStub = ` |
|||
declare module '@angular/core' { |
|||
export function Injectable(opts?: any): ClassDecorator; |
|||
export function inject<T>(token: { new (...args: any[]): T }): T; |
|||
export function inject<T>(token: any): T; |
|||
} |
|||
`;
|
|||
const rxjsStub = ` |
|||
declare module 'rxjs' { |
|||
export class Observable<T> { subscribe(...args: any[]): unknown; } |
|||
} |
|||
`;
|
|||
|
|||
const ambient = abpStub + angularCoreStub + rxjsStub + domStub; |
|||
const sources: Record<string, string> = { |
|||
'/proxy/sample.service.ts': output, |
|||
'/proxy/ambient.d.ts': ambient, |
|||
}; |
|||
|
|||
const compilerOptions: any = { |
|||
target: ts.ScriptTarget.ES2020, |
|||
module: ts.ModuleKind.ES2020, |
|||
moduleResolution: ts.ModuleResolutionKind.NodeJs, |
|||
experimentalDecorators: true, |
|||
emitDecoratorMetadata: true, |
|||
strict: true, |
|||
noEmit: true, |
|||
skipLibCheck: true, |
|||
}; |
|||
|
|||
const baseHost = ts.createCompilerHost(compilerOptions, true); |
|||
const host: any = { |
|||
...baseHost, |
|||
getSourceFile: (fileName: string, languageVersion: any, onError: any) => { |
|||
if (sources[fileName]) { |
|||
return ts.createSourceFile(fileName, sources[fileName], languageVersion, true); |
|||
} |
|||
return baseHost.getSourceFile(fileName, languageVersion, onError); |
|||
}, |
|||
fileExists: (fileName: string) => |
|||
sources[fileName] != null || baseHost.fileExists(fileName), |
|||
readFile: (fileName: string) => |
|||
sources[fileName] ?? baseHost.readFile(fileName), |
|||
}; |
|||
|
|||
const program = ts.createProgram(Object.keys(sources), compilerOptions, host); |
|||
const errors = ts |
|||
.getPreEmitDiagnostics(program) |
|||
.filter((d: any) => |
|||
d.category === ts.DiagnosticCategory.Error && |
|||
d.code !== 6053, |
|||
); |
|||
|
|||
if (errors.length) { |
|||
const messages = errors |
|||
.map((d: any) => { |
|||
const where = d.file |
|||
? (() => { |
|||
const p = d.file.getLineAndCharacterOfPosition(d.start ?? 0); |
|||
const lineText = d.file.text.split('\n')[p.line]; |
|||
return `${d.file.fileName}:${p.line + 1}:${p.character + 1}\n>>> ${lineText}\n>>> ${' '.repeat(p.character)}^`; |
|||
})() |
|||
: '(no file)'; |
|||
return `[${where}] TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`; |
|||
}) |
|||
.join('\n---\n'); |
|||
throw new Error(`Generated proxy did not compile:\n${output}\n=== diagnostics ===\n${messages}`); |
|||
} |
|||
expect(errors).toHaveLength(0); |
|||
}); |
|||
|
|||
test.each([ |
|||
{ |
|||
name: 'DTO upload — single FormData arg', |
|||
signatureParams: [ |
|||
{ name: 'input', type: 'FormData' }, |
|||
{ name: 'config', type: 'Record<string, any>' }, |
|||
], |
|||
bodyOverrides: { method: 'POST', url: "'/api/test/upload-single'", body: 'input' }, |
|||
shouldContain: ['input: FormData', 'body: input'], |
|||
}, |
|||
{ |
|||
name: 'direct upload — FormData arg with custom name', |
|||
signatureParams: [ |
|||
{ name: 'file', type: 'FormData' }, |
|||
{ name: 'config', type: 'Record<string, any>' }, |
|||
], |
|||
bodyOverrides: { method: 'POST', url: "'/api/test/upload-direct'", body: 'file' }, |
|||
shouldContain: ['file: FormData', 'body: file'], |
|||
}, |
|||
{ |
|||
name: 'path + upload mixed — id stays in URL, FormData becomes body', |
|||
signatureParams: [ |
|||
{ name: 'id', type: 'number' }, |
|||
{ name: 'input', type: 'FormData' }, |
|||
{ name: 'config', type: 'Record<string, any>' }, |
|||
], |
|||
bodyOverrides: { method: 'POST', url: '`/api/test/upload-with-path/${id}`', body: 'input' }, |
|||
shouldContain: ['id: number', 'input: FormData', 'body: input'], |
|||
}, |
|||
{ |
|||
name: 'query + upload mixed — tag in params, FormData in body', |
|||
signatureParams: [ |
|||
{ name: 'tag', type: 'string' }, |
|||
{ name: 'input', type: 'FormData' }, |
|||
{ name: 'config', type: 'Record<string, any>' }, |
|||
], |
|||
bodyOverrides: { |
|||
method: 'POST', |
|||
url: "'/api/test/upload-with-query'", |
|||
params: ['tag'], |
|||
body: 'input', |
|||
}, |
|||
shouldContain: ['tag: string', 'input: FormData', 'params: { tag }', 'body: input'], |
|||
}, |
|||
])('upload action signature collapses to FormData ($name)', ({ signatureParams, bodyOverrides, shouldContain }) => { |
|||
const ts = require('typescript'); |
|||
const ctx = buildContext({ |
|||
responseType: 'string', |
|||
responseTypeWithNamespace: 'string', |
|||
...bodyOverrides, |
|||
} as Partial<MockBody>); |
|||
ctx.methods[0].signature.parameters = signatureParams as any; |
|||
const output = render(ctx); |
|||
|
|||
for (const fragment of shouldContain) { |
|||
expect(output).toContain(fragment); |
|||
} |
|||
expect(output).not.toContain('JSON.stringify'); |
|||
|
|||
const abpStub = ` |
|||
declare module '@abp/ng.core' { |
|||
export namespace Rest { |
|||
export interface Config { |
|||
apiName?: string; |
|||
observe?: any; |
|||
skipHandleError?: boolean; |
|||
responseType?: string; |
|||
[key: string]: any; |
|||
} |
|||
export type Observe = any; |
|||
} |
|||
export class RestService { |
|||
request<TBody, TResponse>(req: any, config?: any): import('rxjs').Observable<TResponse>; |
|||
} |
|||
} |
|||
`;
|
|||
const angularCoreStub = ` |
|||
declare module '@angular/core' { |
|||
export function Injectable(opts?: any): ClassDecorator; |
|||
export function inject<T>(token: { new (...args: any[]): T }): T; |
|||
export function inject<T>(token: any): T; |
|||
} |
|||
`;
|
|||
const rxjsStub = ` |
|||
declare module 'rxjs' { |
|||
export class Observable<T> { subscribe(...args: any[]): unknown; } |
|||
} |
|||
`;
|
|||
const domStub = ` |
|||
declare class Blob { constructor(parts?: any[], options?: any); } |
|||
declare class FormData { |
|||
constructor(); |
|||
append(name: string, value: string | Blob, fileName?: string): void; |
|||
get(name: string): any; |
|||
} |
|||
`;
|
|||
const ambient = abpStub + angularCoreStub + rxjsStub + domStub; |
|||
const sources: Record<string, string> = { |
|||
'/proxy/sample.service.ts': output, |
|||
'/proxy/ambient.d.ts': ambient, |
|||
}; |
|||
const compilerOptions: any = { |
|||
target: ts.ScriptTarget.ES2020, |
|||
module: ts.ModuleKind.ES2020, |
|||
moduleResolution: ts.ModuleResolutionKind.NodeJs, |
|||
experimentalDecorators: true, |
|||
emitDecoratorMetadata: true, |
|||
strict: true, |
|||
noEmit: true, |
|||
skipLibCheck: true, |
|||
}; |
|||
const baseHost = ts.createCompilerHost(compilerOptions, true); |
|||
const host: any = { |
|||
...baseHost, |
|||
getSourceFile: (fileName: string, languageVersion: any, onError: any) => |
|||
sources[fileName] |
|||
? ts.createSourceFile(fileName, sources[fileName], languageVersion, true) |
|||
: baseHost.getSourceFile(fileName, languageVersion, onError), |
|||
fileExists: (fileName: string) => |
|||
sources[fileName] != null || baseHost.fileExists(fileName), |
|||
readFile: (fileName: string) => |
|||
sources[fileName] ?? baseHost.readFile(fileName), |
|||
}; |
|||
const program = ts.createProgram(Object.keys(sources), compilerOptions, host); |
|||
const errors = ts |
|||
.getPreEmitDiagnostics(program) |
|||
.filter((d: any) => d.category === ts.DiagnosticCategory.Error && d.code !== 6053); |
|||
if (errors.length) { |
|||
const messages = errors |
|||
.map((d: any) => { |
|||
const where = d.file |
|||
? (() => { |
|||
const p = d.file.getLineAndCharacterOfPosition(d.start ?? 0); |
|||
const lineText = d.file.text.split('\n')[p.line]; |
|||
return `${d.file.fileName}:${p.line + 1}:${p.character + 1}\n>>> ${lineText}\n>>> ${' '.repeat(p.character)}^`; |
|||
})() |
|||
: '(no file)'; |
|||
return `[${where}] TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`; |
|||
}) |
|||
.join('\n---\n'); |
|||
throw new Error(`Upload action proxy did not compile:\n${output}\n=== diagnostics ===\n${messages}`); |
|||
} |
|||
expect(errors).toHaveLength(0); |
|||
}); |
|||
|
|||
test('rendered upload service forwards FormData to restService.request at runtime', () => { |
|||
const ts = require('typescript'); |
|||
const ctx = buildContext({ |
|||
method: 'POST', |
|||
url: "'/api/upload-runtime'", |
|||
responseType: 'string', |
|||
responseTypeWithNamespace: 'string', |
|||
body: 'input', |
|||
}); |
|||
ctx.methods[0].signature.parameters = [ |
|||
{ name: 'input', type: 'FormData' }, |
|||
{ name: 'config', type: 'Record<string, any>' }, |
|||
] as any; |
|||
const output = render(ctx); |
|||
|
|||
const stripped = output |
|||
.replace(/^import .*?;\s*$/gm, '') |
|||
.replace(/@Injectable\(\{[\s\S]*?\}\)\s*\n/g, '') |
|||
.replace(/private restService = inject\(RestService\);/, 'restService;') |
|||
.replace(/this\.restService\.request<[^>]+,\s*[^>]+>/g, 'this.restService.request'); |
|||
|
|||
const transpiled = ts.transpileModule(stripped, { |
|||
compilerOptions: { |
|||
target: ts.ScriptTarget.ES2020, |
|||
module: ts.ModuleKind.CommonJS, |
|||
experimentalDecorators: true, |
|||
}, |
|||
}).outputText; |
|||
|
|||
const restMockCalls: Array<{ body: any; method: string; url: string; headers?: any }> = []; |
|||
const restMock = { |
|||
request: (req: any /* , _config: any */) => { |
|||
restMockCalls.push(req); |
|||
return { subscribe: () => undefined }; |
|||
}, |
|||
}; |
|||
|
|||
const vm = require('vm'); |
|||
const sandbox: Record<string, any> = { exports: {} }; |
|||
vm.createContext(sandbox); |
|||
vm.runInContext(transpiled + '\nexports.SampleService = SampleService;', sandbox); |
|||
const ServiceCls = sandbox.exports.SampleService; |
|||
const instance = new ServiceCls(); |
|||
instance.restService = restMock; |
|||
|
|||
const GlobalFormData = (globalThis as any).FormData; |
|||
const formData = typeof GlobalFormData === 'function' |
|||
? new GlobalFormData() |
|||
: { __isFormData: true, append: () => undefined }; |
|||
instance.getSampleAsync(formData, { apiName: 'Default' }); |
|||
|
|||
expect(restMockCalls).toHaveLength(1); |
|||
expect(restMockCalls[0].body).toBe(formData); |
|||
expect(restMockCalls[0].method).toBe('POST'); |
|||
}); |
|||
}); |
|||