Pitasoft.Client
8.1.2
dotnet add package Pitasoft.Client --version 8.1.2
NuGet\Install-Package Pitasoft.Client -Version 8.1.2
<PackageReference Include="Pitasoft.Client" Version="8.1.2" />
<PackageVersion Include="Pitasoft.Client" Version="8.1.2" />
<PackageReference Include="Pitasoft.Client" />
paket add Pitasoft.Client --version 8.1.2
#r "nuget: Pitasoft.Client, 8.1.2"
#:package Pitasoft.Client@8.1.2
#addin nuget:?package=Pitasoft.Client&version=8.1.2
#tool nuget:?package=Pitasoft.Client&version=8.1.2
Pitasoft.Client
English
Pitasoft.Client is a .NET library designed to simplify the consumption of RESTful services from any application type — including Blazor, Avalonia UI, and MAUI. It provides a robust abstract base class, authentication support, DI extensions, and helpers to handle HTTP requests, JSON serialization, file uploads/downloads, and common API patterns.
Migration Notes (Major Version)
This major version contains breaking changes.
SettingRestServicehas been renamed toRestServiceOptions.IRestServicehas been removed. Consumers should depend on typed service interfaces such asIProductService, or directly on the concrete service when appropriate.RestServiceBaseno longer exposes infrastructure members as part of the public API.HttpClient,ILogger,ITokenProvider,JsonSerializerOptions, andRestServiceBehaviorOptionsare now encapsulated and available only to derived services.AddRestService(...)now supports configurableServiceLifetime, and implementation-only registration also supportsRestServiceOptions.
Typical migration steps:
The Before snippets below show the previous API on purpose.
// Before
var settings = new SettingRestService { UriString = "https://api.example.com/" };
builder.Services.AddRestService<IProductService, ProductService>(settings);
// After
var options = new RestServiceOptions { BaseAddress = "https://api.example.com/" };
builder.Services.AddRestService<IProductService, ProductService>(options);
// Before
public class ProductService : IRestService
{
}
// After
public interface IProductService
{
}
public class ProductService : RestServiceBase, IProductService
{
}
Features
- Base REST Service:
RestServiceBaseprovides methods for GET, POST, PUT, PATCH, and DELETE operations, including batch processing and body-based deletions. - Logging Support: Built-in integration with
ILoggerfor request/response tracing and error logging. - Thread-safe Authentication: Bearer tokens are applied per-request, ensuring safety in concurrent environments like Blazor.
- Enhanced Result and Error Mapping: Preserves backend functional
StatusResultcontracts on successful responses and maps relevant HTTP error codes (400, 401, 403, 404, 409, 422, 429, 503) when transport-level failures occur. - Metadata Preservation: Preserves
Pitasoft.Resultmetadata when it arrives either through flattened JSON result payloads or through known HTTP headers such asLocation,ETag, andRetry-After. - Paginated Results: Built-in support for
ResultPaged<T>viaGetPagedAsync, withPagingParametersandIParametersoverloads, andIsEmptyconvenience property. - Bearer Token Authentication: Automatic injection of
Authorization: Bearerheaders via theITokenProviderinterface. - Token Refresh / 401 Retry: Override
TryRefreshTokenAsyncto implement refresh token logic with automatic request retry for replay-safe requests. - Transient Error Retry: Override
GetRetryDelayAsyncto implement custom retry-delay strategies for transient errors (TooManyRequests,ServiceUnavailable) on replay-safe requests. - File Upload:
UploadAsyncsendsMultipartFormDataContentfor uploading files and form data. Supports bothResultEntity<T>andResult(no-body) overloads. - File Download with Progress:
DownloadAsyncstreams binary content with optionalIProgress<long>reporting. - Connection Error Detection: Distinguishes network-level errors (
SocketException) from generic HTTP errors usingStatusResult.ConnectionError. - Modern HttpClient Management: Full support for
IHttpClientFactoryfor efficient connection management, socket pooling, and DI integration. - DI Extensions:
AddRestServiceextension methods for registering services fluently in Blazor, MAUI, and AvaloniaProgram.cs/MauiProgram.cs. - Flexible Configuration:
RestServiceOptionscentralizes base URL, JSON options, token provider, default headers, request timeout, HTTP version settings, and behavior options. - Query Helpers:
QueryHelperssafely and efficiently builds query strings from dictionaries,IParameters, or key-value pairs. - High Performance: Optimized for .NET 8, 9, and 10 using
SearchValues<char>,ReadOnlySpan<char>, and efficient memory management. - Result Handling: Integrated with
Pitasoft.Resultfor consistent and expressive response handling.
Performance Optimizations (.NET 8/9/10)
- Zero-allocation URI parsing:
ReadOnlySpan<char>andAsSpan()avoid unnecessary string allocations during query string manipulation. - Fast character search:
SearchValues<char>enables ultra-fast detection of?and#control characters. - Memory efficiency:
StringBuilderpre-sizing and optimized JSON deserialization paths.
| Method | Mean | Ratio | Allocated |
|---|---|---|---|
AddSingleQueryString |
34.31 ns | 1.00 | 456 B |
AddMultipleQueryStrings |
118.62 ns | 1.00 | 960 B |
Installation
dotnet add package Pitasoft.Client
Core Concepts
Result Types (from Pitasoft.Result)
All methods return a result object that encapsulates the response status, data, errors, and optional result metadata:
| Type | Description |
|---|---|
Result |
Simple result with no entity payload. Used for DELETE operations. |
ResultEntity<T> |
Result containing a single entity of type T. |
ResultEntities<T> |
Result containing a collection of entities of type T. |
ResultPaged<T> |
Result with a paged collection: includes Entities, Page, PageSize, TotalCount, and IsEmpty. |
ResultBatch<T> |
Result from a batch (bulk) POST operation. |
All result wrappers also inherit the common Pitasoft.Result base contract:
StatusErrorsResultCodeCalculationTimeMetadata(IHasMetadata), projected byPitasoft.ResultJSON converters through flattened top-level fields such aslocation,etag,retryAfter,contentLocation,lastModified, andcacheControl
Status Values (StatusResult)
StatusResult primarily represents the functional outcome reported by the backend or data/application layer. Some values map directly to HTTP semantics, while others describe business, processing, persistence, or application outcomes that can still travel inside an HTTP 200 response.
| Value | Description |
|---|---|
Ok |
Operation completed successfully. It may return no entity, one entity, or multiple entities. |
Added |
A data creation operation completed successfully. |
Updated |
A data update operation completed successfully. |
Deleted |
A data deletion operation completed successfully. |
NoExist |
The operation completed correctly, but the requested data does not exist in the data source. |
Warning |
The operation completed, but with warnings. |
CancelOperation |
The operation was explicitly cancelled by the caller. |
ValidationError |
Submitted input data failed validation before processing. |
DataError |
An error occurred while processing data. |
DatabaseError |
An error occurred while executing the database operation. |
ConcurrencyError |
A concurrency error occurred while modifying persisted data. |
ConnectionError |
The client could not connect to the backend server. |
HttpError |
An error occurred while HttpClient was executing the HTTP operation. |
Error |
An application-level error occurred. |
Unauthorized |
HTTP 401 semantics: the user is not authorized to perform the operation. |
Forbidden |
HTTP 403: The server refused to authorize the request. |
NotFound |
HTTP 404: The requested resource was not found. |
Conflict |
HTTP 409: The request conflicts with the current state. |
UnprocessableEntity |
HTTP 422: The request is structurally valid but semantically invalid. |
TooManyRequests |
HTTP 429: Rate limit exceeded. |
ServiceUnavailable |
HTTP 503: The server is temporarily unavailable. |
ChangePassword |
The user must change their password before continuing to use the application. |
Exception |
The backend explicitly reports that an exception occurred during execution. |
None |
No operation was performed. This should not normally be returned by a completed application flow. |
Usage
1. Define Your Service
Inherit from RestServiceBase and inject IHttpClientFactory (recommended) or pass an HttpClient directly.
public interface IProductService
{
Task<ResultEntities<Product>> GetAllAsync(CancellationToken ct = default);
Task<ResultPaged<Product>> GetPagedAsync(int page, int pageSize, CancellationToken ct = default);
Task<ResultEntity<Product>> GetByIdAsync(int id, CancellationToken ct = default);
Task<ResultEntity<Product>> CreateAsync(Product product, CancellationToken ct = default);
Task<ResultEntity<Product>> UpdateAsync(int id, Product product, CancellationToken ct = default);
Task<ResultEntity<Product>> PatchAsync(int id, object patch, CancellationToken ct = default);
Task<Result> DeleteAsync(int id, CancellationToken ct = default);
Task<Result> DeactivateAsync(int id, string reason, CancellationToken ct = default);
Task<ResultEntity<Stream>> GetExportStreamAsync(int id, CancellationToken ct = default);
}
public class ProductService : RestServiceBase, IProductService
{
// Recommended: use IHttpClientFactory for proper connection management and logging
public ProductService(IHttpClientFactory factory, ILogger<ProductService> logger)
: base(factory, nameof(ProductService), logger) { }
public Task<ResultEntities<Product>> GetAllAsync(CancellationToken ct = default)
=> GetsAsync<Product>("api/products", ct);
public Task<ResultPaged<Product>> GetPagedAsync(int page, int pageSize, CancellationToken ct = default)
=> GetPagedAsync<Product>("api/products", new PagingParameters { Page = page, PageSize = pageSize }, ct);
public Task<ResultEntity<Product>> GetByIdAsync(int id, CancellationToken ct = default)
=> GetAsync<Product>($"api/products/{id}", ct);
public Task<ResultEntity<Product>> CreateAsync(Product product, CancellationToken ct = default)
=> PostAsync("api/products", product, ct);
public Task<ResultEntity<Product>> UpdateAsync(int id, Product product, CancellationToken ct = default)
=> PutAsync($"api/products/{id}", product, ct);
public Task<ResultEntity<Product>> PatchAsync(int id, object patch, CancellationToken ct = default)
=> base.PatchAsync<object, Product>($"api/products/{id}", patch, ct);
public Task<Result> DeleteAsync(int id, CancellationToken ct = default)
=> base.DeleteAsync($"api/products/{id}", ct);
public Task<Result> DeactivateAsync(int id, string reason, CancellationToken ct = default)
=> base.DeleteAsync($"api/products/{id}", new { Reason = reason }, ct);
public Task<ResultEntity<Stream>> GetExportStreamAsync(int id, CancellationToken ct = default)
=> DownloadStreamAsync($"api/products/{id}/export", ct);
}
Note: automatic retry is enabled for requests whose body can be recreated safely by the client pipeline. Operations that receive external
HttpContentorMultipartFormDataContentinstances do not retry automatically, because those bodies may be stream-backed or otherwise non-replayable.
2. Register in Dependency Injection
Use the built-in AddRestService extension methods in Program.cs (Blazor / MAUI) or App.axaml.cs (Avalonia):
// Option A — Simple: base URL + optional JSON options
builder.Services.AddRestService<IProductService, ProductService>(
baseUrl: "https://api.example.com/",
jsonSerializerOptions: new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Option B — RestServiceOptions: full configuration object
builder.Services.AddRestService<IProductService, ProductService>(new RestServiceOptions
{
BaseAddress = "https://api.example.com/",
JsonSerializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true },
TokenProvider = new MyTokenProvider(),
DefaultHeaders = new Dictionary<string, IEnumerable<string>>
{
{ "X-Api-Version", new[] { "2" } },
{ "X-Tenant-Id", new[] { "acme", "tenant-backup" } }
},
Timeout = TimeSpan.FromSeconds(30),
HttpVersion = HttpVersion.Version20,
HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
Behavior = new RestServiceBehaviorOptions
{
EnableAutomaticRetries = true,
EnableTokenRefreshRetry = true,
NormalizeEmptySuccessToOk = true,
PreserveHttpStatusCodeAsResultCode = true,
TreatTimeoutAsHttpError = true,
MaxTransientRetryAttempts = 3
}
});
// Option C — Without interface (concrete type only)
builder.Services.AddRestService<ProductService>("https://api.example.com/");
// Option C.1 — Without interface using RestServiceOptions
builder.Services.AddRestService<ProductService>(new RestServiceOptions
{
BaseAddress = "https://api.example.com/",
Timeout = TimeSpan.FromSeconds(20),
HttpVersion = HttpVersion.Version20,
HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
Behavior = new RestServiceBehaviorOptions
{
EnableAutomaticRetries = true
}
});
// Option C.2 — Custom lifetime
builder.Services.AddRestService<IProductService, ProductService>(
"https://api.example.com/",
serviceLifetime: ServiceLifetime.Singleton);
// Option D — Manual IHttpClientFactory (full control)
builder.Services.AddHttpClient(nameof(ProductService), client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
builder.Services.AddScoped<IProductService, ProductService>();
3. Consume the Service
// In a Blazor component, MAUI ViewModel, or Avalonia ViewModel:
var result = await _productService.GetPagedAsync(page: 1, pageSize: 20);
if (result.Status == StatusResult.Ok)
{
// result.Entities → IReadOnlyList<Product>
// result.Page → current page
// result.PageSize
// result.TotalCount
Console.WriteLine($"Page {result.Page} — {result.TotalCount} total products");
}
4. Platform Quickstarts
Use these as practical starting points depending on the application type:
| Platform | Typical registration point | Typical consumption point | Recommended notes |
|---|---|---|---|
| Blazor WebAssembly / Server | Program.cs |
Component, service, or ViewModel | Use ITokenProvider backed by local storage or auth state. Treat ConnectionError and HttpError as recoverable UI failures. |
| MAUI | MauiProgram.cs |
ViewModel or service layer | Store tokens in SecureStorage. Use CancelOperation for user-driven cancellation and HttpError for timeout-style failures. |
| Avalonia UI | App.axaml.cs or composition root |
ViewModel or service layer | Prefer typed services per feature area. Surface ValidationError in forms and Warning as non-blocking feedback. |
| WPF | App.xaml.cs / DI bootstrapper |
ViewModel or application service | Keep RestServiceBase usage in infrastructure/services, not directly in views. Use CancellationTokenSource for user-cancellable operations. |
| One Platform | Platform bootstrap/composition root | Feature service or screen model | Preserve backend StatusResult semantics and only adapt them in the presentation layer. |
Minimal examples:
// Blazor / WPF / Avalonia / One Platform
builder.Services.AddRestService<IProductService, ProductService>("https://api.example.com/");
// MAUI
builder.Services.AddRestService<IProductService, ProductService>(new RestServiceOptions
{
BaseAddress = "https://api.example.com/",
TokenProvider = new MauiTokenProvider()
});
5. UI Status Handling Guide
For UI applications, a simple default mapping usually works well:
StatusResult |
Typical UI treatment |
|---|---|
Ok, Added, Updated, Deleted |
Continue normally, refresh UI or show success feedback if appropriate |
NoExist |
Render an empty state, “not available”, or “record no longer exists” flow |
Warning |
Show non-blocking feedback such as a banner or toast |
ValidationError |
Show result.Errors inline at form or field level |
UnprocessableEntity |
Show business-rule feedback, often as form-level or action-level message |
ConnectionError |
Show offline / connectivity message and offer retry |
HttpError |
Show generic communication failure or timeout message |
Unauthorized |
Redirect to login or refresh auth context |
Forbidden |
Show access-denied message |
CancelOperation |
Usually no error UI; user or caller intentionally cancelled |
Exception, Error, DatabaseError, DataError |
Show blocking error or fallback message and log/telemetry |
Authentication — Bearer Token (ITokenProvider)
Implement ITokenProvider to supply tokens from any source (in-memory, SecureStorage in MAUI, AuthenticationStateProvider in Blazor, etc.).
// 1. Implement ITokenProvider
public class MyTokenProvider : ITokenProvider
{
private string? _token;
public void SetToken(string? token) => _token = token;
public Task<string?> GetTokenAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(_token);
}
RestServiceBase automatically calls GetTokenAsync before every request and sets the Authorization: Bearer {token} header. If the token is null or empty, the header is removed.
// 2. Register with DI
builder.Services.AddSingleton<ITokenProvider, MyTokenProvider>();
builder.Services.AddRestService<IProductService, ProductService>(new RestServiceOptions
{
BaseAddress = "https://api.example.com/",
TokenProvider = serviceProvider.GetRequiredService<ITokenProvider>()
});
Blazor WASM — Integration with AuthenticationStateProvider
public class BlazorTokenProvider : ITokenProvider
{
private readonly ILocalStorageService _localStorage;
public BlazorTokenProvider(ILocalStorageService localStorage)
=> _localStorage = localStorage;
public async Task<string?> GetTokenAsync(CancellationToken cancellationToken = default)
=> await _localStorage.GetItemAsStringAsync("access_token");
}
MAUI — Integration with SecureStorage
public class MauiTokenProvider : ITokenProvider
{
public async Task<string?> GetTokenAsync(CancellationToken cancellationToken = default)
=> await SecureStorage.Default.GetAsync("access_token");
}
Token Refresh / 401 Retry (TryRefreshTokenAsync)
When the server returns HTTP 401, RestServiceBase calls TryRefreshTokenAsync. If it returns true, the request is automatically retried once with the new token.
public class ProductService : RestServiceBase, IProductService
{
private readonly IAuthService _authService;
public ProductService(IHttpClientFactory factory, IAuthService authService)
: base(factory, nameof(ProductService))
{
_authService = authService;
}
protected override async Task<bool> TryRefreshTokenAsync(CancellationToken cancellationToken = default)
{
var refreshed = await _authService.RefreshTokenAsync(cancellationToken);
return refreshed; // true → retry the original request; false → propagate Unauthorized
}
}
Transient Error Retry (GetRetryDelayAsync)
When the server returns 429 Too Many Requests or 503 Service Unavailable, RestServiceBase calls GetRetryDelayAsync. Return a TimeSpan to delay and retry the request, or null to stop retrying.
public class ProductService : RestServiceBase, IProductService
{
protected override Task<TimeSpan?> GetRetryDelayAsync(
IResult result,
int attemptNumber,
CancellationToken cancellationToken = default)
{
// Retry up to 3 times with exponential back-off
if (attemptNumber > 3)
return Task.FromResult<TimeSpan?>(null);
var delay = TimeSpan.FromSeconds(Math.Pow(2, attemptNumber)); // 2s, 4s, 8s
return Task.FromResult<TimeSpan?>(delay);
}
}
Note:
GetRetryDelayAsyncapplies toSendAsyncandDownloadAsync. TheattemptNumberparameter starts at1on the first retry. For operations using externally suppliedHttpContent/MultipartFormDataContent, automatic retry is intentionally disabled unless the request body is recreated by the service method itself.
Paginated Results (GetPagedAsync)
GetPagedAsync returns a ResultPaged<T> with pagination metadata.
// With PagingParameters (Page + PageSize)
public Task<ResultPaged<Product>> GetPagedAsync(int page, int pageSize, CancellationToken ct = default)
=> GetPagedAsync<Product>("api/products", new PagingParameters { Page = page, PageSize = pageSize }, ct);
// With IParameters (custom filter + paging)
public Task<ResultPaged<Product>> SearchAsync(ProductParameters parameters, CancellationToken ct = default)
=> GetPagedAsync<Product>("api/products", parameters, ct);
// Without parameters (query string already embedded in path)
public Task<ResultPaged<Product>> GetPagedRawAsync(CancellationToken ct = default)
=> GetPagedAsync<Product>("api/products?page=1&pageSize=10", ct);
Note:
PagingParametersserializes as?Page=1&PageSize=20in the query string. CustomIParametersclasses must implementGetParameters()returningIEnumerable<KeyValuePair<string, string>>.
File Upload (UploadAsync)
UploadAsync accepts MultipartFormDataContent and sends it as provided. Because multipart bodies may include non-replayable streams, these overloads do not automatically retry after 401, 429, or 503.
public async Task<ResultEntity<UploadResponse>> UploadAvatarAsync(Stream fileStream, string fileName, CancellationToken ct = default)
{
var content = new MultipartFormDataContent();
content.Add(new StreamContent(fileStream), "file", fileName);
return await UploadAsync<UploadResponse>("api/users/avatar", content, ct);
}
File Download with Progress (DownloadAsync)
public async Task<ResultEntity<byte[]>> DownloadReportAsync(
int reportId,
IProgress<long>? progress = null,
CancellationToken ct = default)
{
return await DownloadAsync($"api/reports/{reportId}/download", progress, ct);
}
// Usage — e.g. in a MAUI / Avalonia ViewModel:
var progress = new Progress<long>(bytesRead => Console.WriteLine($"Downloaded: {bytesRead} bytes"));
var result = await _reportService.DownloadReportAsync(42, progress, cancellationToken);
if (result.Status == StatusResult.Ok)
{
await File.WriteAllBytesAsync("report.pdf", result.Entity!, cancellationToken);
}
PATCH — Partial Update (PatchAsync)
Use PatchAsync to send partial entity updates without replacing the entire resource.
// Same input/output type
public Task<ResultEntity<Product>> PatchNameAsync(int id, string newName, CancellationToken ct = default)
=> PatchAsync($"api/products/{id}", new { Name = newName }, ct);
// Different input/output types
public Task<ResultEntity<ProductDetail>> PatchStatusAsync(int id, StatusPatch patch, CancellationToken ct = default)
=> PatchAsync<StatusPatch, ProductDetail>($"api/products/{id}", patch, ct);
// Custom result type
public Task<MyCustomResult> PatchWithCustomResultAsync(int id, object patch, MyCustomResult initial, CancellationToken ct = default)
=> PatchAsync($"api/products/{id}", patch, initial, ct);
Connection Error Handling
RestServiceBase distinguishes transport/client failures from functional backend results. When the backend already returns a Result, that functional StatusResult is preserved. When the request fails before a successful functional response is available, these client-side scenarios are handled automatically:
| Scenario | StatusResult |
Cause |
|---|---|---|
| Cancelled by caller | CancelOperation |
OperationCanceledException with the provided CancellationToken cancelled |
| Timeout / aborted HTTP operation | HttpError |
OperationCanceledException without caller cancellation |
| No network / connection refused | ConnectionError |
HttpRequestException + SocketException |
| HTTP/client communication failure | HttpError |
Any other Exception |
var result = await _productService.GetAllAsync();
switch (result.Status)
{
case StatusResult.Ok:
// Use result data
break;
case StatusResult.ConnectionError:
// Show offline banner / retry button
break;
case StatusResult.Unauthorized:
// Redirect to login
break;
case StatusResult.ValidationError:
// Show result.Errors to user
break;
case StatusResult.CancelOperation:
// Request was cancelled — no action needed
break;
}
For successful responses, ResultCode preserves the HTTP status code when one exists. This includes empty successful responses such as 200 OK with no body or 204 No Content. When no Result payload can be deserialized from a successful response, RestServiceBase normalizes the final status to Ok.
Result Metadata
Pitasoft.Client preserves known Pitasoft.Result metadata from both sources:
- flattened JSON result payloads produced by
Pitasoft.Resultconverters - real HTTP headers such as
Location,ETag,Retry-After,Content-Location,Last-Modified, andCache-Control
When the same known metadata appears both in the result payload and in the HTTP headers, the HTTP header value wins because it reflects the actual transport response received by the client. Known metadata from failed HTTP responses is applied before On* hooks run, so overrides such as OnTooManyRequests can inspect Retry-After directly.
var result = await _productService.GetAsync(42, ct);
if (result.Metadata?.TryGetValue("ETag", out var etag) == true)
{
// Use ETag for caching or optimistic concurrency
}
if (result.Metadata?.TryGetValue("Location", out var location) == true)
{
// Follow the created resource URI
}
if (result.Metadata?.TryGetValue("Retry-After", out var retryAfter) == true)
{
// Show retry advice in UI or schedule the next attempt
}
Interoperability With Pitasoft.Result.AspNetCore
If the backend uses Pitasoft.Result.AspNetCore, keep serializer configuration aligned with the server-side result converters.
Recommended baseline:
- use
ResultJsonSerializerOptions.Create()when creating a dedicatedPitasoft.Clientserializer configuration - or enrich an existing
JsonSerializerOptionsinstance withResultJsonSerializerOptions.Add(options)
This is especially important for:
ResultEntities<T>ResultPaged<T>- structured
ErrorCollectionpayloads
When consuming the default ASP.NET Core adapter behavior, also keep in mind:
NoExistis typically returned as200 OKNotFoundis reserved for explicit404Deletedcommonly travels as204 No Content;Pitasoft.Clientpreserves that semantic asStatusResult.DeletedonDELETEoperations
using Pitasoft.Result.AspNetCore;
var serializerOptions = ResultJsonSerializerOptions.Create();
var options = new RestServiceOptions
{
BaseAddress = "https://api.example.com/",
JsonSerializerOptions = serializerOptions
};
Default Headers and Timeout (RestServiceOptions)
var options = new RestServiceOptions
{
BaseAddress = "https://api.example.com/",
Timeout = TimeSpan.FromSeconds(15),
HttpVersion = HttpVersion.Version20,
HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
DefaultHeaders = new Dictionary<string, IEnumerable<string>>
{
{ "X-Api-Key", new[] { "your-api-key" } },
{ "X-Api-Version", new[] { "2" } },
{ "X-Tenant-Id", new[] { "acme", "tenant-backup" } }
}
};
// Pass directly to the RestServiceBase constructor:
public class ProductService : RestServiceBase
{
public ProductService(RestServiceOptions options) : base(options) { }
}
Behavior Options (RestServiceBehaviorOptions)
Use RestServiceOptions.Behavior to control retry, timeout, and success-normalization behavior without overloading the main HTTP options object.
var options = new RestServiceOptions
{
BaseAddress = "https://api.example.com/",
Behavior = new RestServiceBehaviorOptions
{
EnableAutomaticRetries = true,
EnableTokenRefreshRetry = true,
NormalizeEmptySuccessToOk = true,
PreserveHttpStatusCodeAsResultCode = true,
TreatTimeoutAsHttpError = true,
MaxTransientRetryAttempts = 3
}
};
| Option | Default | Purpose |
|---|---|---|
EnableAutomaticRetries |
true |
Enables transient retry handling for replay-safe requests |
EnableTokenRefreshRetry |
true |
Allows a 401 to trigger token refresh and one retry |
NormalizeEmptySuccessToOk |
true |
Converts successful empty responses from None to Ok |
PreserveHttpStatusCodeAsResultCode |
true |
Preserves the HTTP status code in ResultCode when a real HTTP response exists |
TreatTimeoutAsHttpError |
true |
Maps timeout-like OperationCanceledException paths to HttpError unless the caller explicitly cancelled |
MaxTransientRetryAttempts |
null |
Optional cap for replay-safe transient retries |
Query Helpers (QueryHelpers)
QueryHelpers provides static methods to safely compose query strings. All values are URL-encoded automatically.
// Single key-value
var uri = QueryHelpers.AddQueryString("api/products", "category", "electronics");
// → "api/products?category=electronics"
// Dictionary
var uri = QueryHelpers.AddQueryString("api/products", new Dictionary<string, string>
{
{ "category", "electronics" },
{ "inStock", "true" }
});
// → "api/products?category=electronics&inStock=true"
// IParameters (e.g. PagingParameters or custom EntityParameters)
var uri = QueryHelpers.AddQueryString("api/products", new PagingParameters { Page = 2, PageSize = 10 });
// → "api/products?Page=2&PageSize=10"
// Preserves existing query string and anchors
var uri = QueryHelpers.AddQueryString("api/products?sort=asc#section", "page", "3");
// → "api/products?sort=asc&page=3#section"
Virtual Hooks (Override in Derived Classes)
RestServiceBase exposes protected virtual methods to customize behavior without re-implementing the full request pipeline. Hooks tied to 401/403/404/409/429/503 react to HTTP semantics, while successful responses still preserve any functional StatusResult returned by the backend:
| Method | When Called | Default |
|---|---|---|
OnUnauthorized(IResult) |
HTTP 401 received and not retried | no-op |
OnForbidden(IResult) |
HTTP 403 received | no-op |
OnNotFound(IResult) |
HTTP 404 received | no-op |
OnConflict(IResult) |
HTTP 409 received | no-op |
OnTooManyRequests(IResult) |
HTTP 429 received | no-op |
OnServiceUnavailable(IResult) |
HTTP 503 received | no-op |
OnError(IResult) |
Application/infrastructure error path, including generic HTTP/client failures | no-op |
OnChangePassword(IResult) |
Server signals StatusResult.ChangePassword |
no-op |
HandleSatisfactoryAsync(...) |
After a successful HTTP response | Routes to specific On* hooks |
HandleErrorsAsync(...) |
After a failed HTTP response | Sets Status, parses 400/422 error payloads, applies known metadata, calls On* hooks |
TryRefreshTokenAsync(...) |
On HTTP 401 before propagating | Returns false |
GetRetryDelayAsync(IResult, int, CancellationToken) |
On transient error (429/503) | Returns null (no retry) |
public class ProductService : RestServiceBase
{
protected override void OnUnauthorized(IResult result)
{
// e.g. fire an event, navigate to login, clear local state
}
protected override void OnForbidden(IResult result)
{
// e.g. show "access denied" message
}
protected override void OnNotFound(IResult result)
{
// e.g. navigate to a 404 page
}
protected override void OnError(IResult result)
{
// e.g. log to Sentry, show a toast notification
}
protected override async Task<bool> TryRefreshTokenAsync(CancellationToken cancellationToken = default)
{
// Return true if token was refreshed and the request should be retried
return await _authService.RefreshAsync(cancellationToken);
}
protected override Task<TimeSpan?> GetRetryDelayAsync(IResult result, int attemptNumber, CancellationToken cancellationToken = default)
{
// Return a delay to retry, or null to stop
return Task.FromResult(attemptNumber <= 3 ? TimeSpan.FromSeconds(Math.Pow(2, attemptNumber)) : (TimeSpan?)null);
}
}
All Available Constructors
RestServiceBase supports seven construction patterns:
// 1. Base address only
protected RestServiceBase(string baseAddress, ILogger? logger = null)
// 2. Base address + JSON options
protected RestServiceBase(string baseAddress, JsonSerializerOptions jsonSerializerOptions, ILogger? logger = null)
// 3. Existing HttpClient
protected RestServiceBase(HttpClient client, ILogger? logger = null)
// 4. Existing HttpClient + JSON options
protected RestServiceBase(HttpClient client, JsonSerializerOptions jsonSerializerOptions, ILogger? logger = null)
// 5. IHttpClientFactory + named client (recommended for DI)
protected RestServiceBase(IHttpClientFactory httpClientFactory, string clientName, ILogger? logger = null)
// 6. IHttpClientFactory + named client + JSON options
protected RestServiceBase(IHttpClientFactory httpClientFactory, string clientName, JsonSerializerOptions jsonSerializerOptions, ILogger? logger = null)
// 7. RestServiceOptions (full configuration: base address, JSON, token, headers, timeout, HTTP version, behavior)
protected RestServiceBase(RestServiceOptions options, ILogger? logger = null)
Public Surface
RestServiceBase is the public base abstraction of the package. Consumers are expected to work with typed service interfaces such as IProductService, not through a generic IRestService contract.
HttpClient is intentionally not exposed as part of the public service contract. Derived services still access it internally through RestServiceBase, but consumers are encouraged to use typed service methods instead of raw HTTP calls.
Infrastructure details such as ILogger, ITokenProvider, JsonSerializerOptions, and RestServiceBehaviorOptions remain configuration and implementation concerns. They can be used from derived services, but they are not exposed as part of the service contract that consumers should depend on.
When you register a service with RestServiceOptions, those options are passed to that service instance during construction. They are not registered as a shared RestServiceOptions singleton in the container.
ITokenProvider
public interface ITokenProvider
{
Task<string?> GetTokenAsync(CancellationToken cancellationToken = default);
}
Castellano
Pitasoft.Client es una librería .NET diseñada para simplificar el consumo de servicios RESTful desde cualquier tipo de aplicación — incluyendo Blazor, Avalonia UI y MAUI. Proporciona una clase base abstracta robusta, soporte de autenticación, extensiones de DI y utilidades para manejar peticiones HTTP, serialización JSON, subida/descarga de archivos y patrones comunes de API.
Notas de Migración (Versión Major)
Esta versión major incluye cambios incompatibles.
SettingRestServicepasa a llamarseRestServiceOptions.IRestServiceha sido eliminada. Los consumidores deberían depender de interfaces tipadas del dominio, comoIProductService, o directamente del servicio concreto cuando tenga sentido.RestServiceBasedeja de exponer miembros de infraestructura como parte de la API pública.HttpClient,ILogger,ITokenProvider,JsonSerializerOptionsyRestServiceBehaviorOptionsquedan encapsulados y solo están disponibles para clases derivadas.AddRestService(...)ahora permite configurarServiceLifetime, y el registro solo por implementación también soportaRestServiceOptions.
Pasos típicos de migración:
Los fragmentos marcados como Antes muestran intencionadamente la API anterior.
// Antes
var settings = new SettingRestService { UriString = "https://api.example.com/" };
builder.Services.AddRestService<IProductService, ProductService>(settings);
// Después
var options = new RestServiceOptions { BaseAddress = "https://api.example.com/" };
builder.Services.AddRestService<IProductService, ProductService>(options);
// Antes
public class ProductService : IRestService
{
}
// Después
public interface IProductService
{
}
public class ProductService : RestServiceBase, IProductService
{
}
Características
- Servicio REST Base:
RestServiceBaseproporciona métodos para operaciones GET, POST, PUT, PATCH y DELETE, incluyendo soporte para procesamiento por lotes (batch) y borrados con cuerpo. - Soporte de Logging: Integración nativa con
ILoggerpara trazas de peticiones/respuestas y registro de errores. - Autenticación Thread-safe: Los tokens Bearer se aplican por petición, garantizando seguridad en entornos concurrentes como Blazor.
- Mapeo Mejorado de Resultados y Errores: Conserva los contratos funcionales de
StatusResultdevueltos por el backend en respuestas satisfactorias y mapea códigos HTTP relevantes (400, 401, 403, 404, 409, 422, 429, 503) cuando se producen fallos a nivel de transporte. - Preservación de Metadatos: Conserva los metadatos de
Pitasoft.Resultcuando llegan tanto en payloads JSON aplanados como en headers HTTP conocidos comoLocation,ETagyRetry-After. - Resultados Paginados: Soporte nativo de
ResultPaged<T>medianteGetPagedAsync, con sobrecargas paraPagingParameterseIParameters, y propiedad de convenienciaIsEmpty. - Autenticación con Bearer Token: Inyección automática de cabeceras
Authorization: Bearera través de la interfazITokenProvider. - Refresco de Token / Reintento 401: Sobrescribe
TryRefreshTokenAsyncpara implementar lógica de refresh token con reintento automático en peticiones seguras de reproducir. - Reintento ante Errores Transitorios: Sobrescribe
GetRetryDelayAsyncpara implementar estrategias de reintento personalizadas ante errores transitorios (TooManyRequests,ServiceUnavailable) en peticiones seguras de reproducir. - Subida de Archivos:
UploadAsyncenvíaMultipartFormDataContentpara subir ficheros y formularios. Soporta sobrecargas conResultEntity<T>yResult(sin cuerpo de respuesta). - Descarga de Archivos con Progreso:
DownloadAsyncdescarga contenido binario con reporte opcional medianteIProgress<long>. - Detección de Error de Conexión: Distingue errores de red (
SocketException) de errores HTTP genéricos usandoStatusResult.ConnectionError. - Gestión Moderna de HttpClient: Soporte completo para
IHttpClientFactorycon gestión eficiente de conexiones, pooling de sockets e integración con DI. - Extensiones de DI: Métodos de extensión
AddRestServicepara registrar servicios de forma fluida enProgram.cs/MauiProgram.csde Blazor, MAUI y Avalonia. - Configuración Flexible:
RestServiceOptionscentraliza URL base, opciones JSON, proveedor de token, cabeceras predeterminadas, timeout, versión HTTP y opciones de comportamiento. - Helpers de Consulta:
QueryHelpersconstruye query strings de forma segura y eficiente desde diccionarios,IParameterso pares clave-valor. - Alto Rendimiento: Optimizado para .NET 8, 9 y 10 utilizando
SearchValues<char>,ReadOnlySpan<char>y gestión eficiente de memoria. - Manejo de Resultados: Integrado con
Pitasoft.Resultpara un manejo de respuestas consistente y expresivo.
Optimizaciones de Rendimiento (.NET 8/9/10)
- Análisis de URIs sin asignaciones:
ReadOnlySpan<char>yAsSpan()evitan la creación innecesaria de cadenas temporales. - Búsqueda rápida de caracteres:
SearchValues<char>detecta?y#con mínima sobrecarga. - Eficiencia de memoria: Pre-dimensionamiento de
StringBuildery rutas de deserialización JSON optimizadas.
| Método | Media | Ratio | Memoria |
|---|---|---|---|
AddSingleQueryString |
34.31 ns | 1.00 | 456 B |
AddMultipleQueryStrings |
118.62 ns | 1.00 | 960 B |
Instalación
dotnet add package Pitasoft.Client
Conceptos Clave
Tipos de Resultado (de Pitasoft.Result)
Todos los métodos devuelven un objeto resultado que encapsula el estado de la respuesta, los datos, los errores y los metadatos opcionales del resultado:
| Tipo | Descripción |
|---|---|
Result |
Resultado simple sin payload de entidad. Se usa en operaciones DELETE. |
ResultEntity<T> |
Resultado que contiene una única entidad de tipo T. |
ResultEntities<T> |
Resultado que contiene una colección de entidades de tipo T. |
ResultPaged<T> |
Resultado con colección paginada: incluye Entities, Page, PageSize, TotalCount e IsEmpty. |
ResultBatch<T> |
Resultado de una operación POST en lote (bulk). |
Todos los wrappers de resultado heredan además el contrato base común de Pitasoft.Result:
StatusErrorsResultCodeCalculationTimeMetadata(IHasMetadata), proyectado por los conversores JSON dePitasoft.Resultmediante campos de primer nivel comolocation,etag,retryAfter,contentLocation,lastModifiedycacheControl
Valores de Estado (StatusResult)
StatusResult representa principalmente el resultado funcional informado por el backend o por la capa de datos/aplicación. Algunos valores mapean directamente semántica HTTP, mientras que otros describen resultados de negocio, procesamiento, persistencia o aplicación que igualmente pueden viajar dentro de una respuesta HTTP 200.
| Valor | Descripción |
|---|---|
Ok |
La operación se completó correctamente. Puede devolver ninguna entidad, una entidad o varias entidades. |
Added |
Una operación de alta de datos se completó correctamente. |
Updated |
Una operación de modificación de datos se completó correctamente. |
Deleted |
Una operación de borrado de datos se completó correctamente. |
NoExist |
La operación se ejecutó correctamente, pero los datos solicitados no existen en el origen de datos. |
Warning |
La operación se completó, pero con advertencias. |
CancelOperation |
La operación fue cancelada explícitamente por el llamador. |
ValidationError |
Los datos enviados fallaron la validación antes de procesarse. |
DataError |
Se produjo un error durante el procesado de datos. |
DatabaseError |
Se produjo un error al ejecutar la operación en la base de datos. |
ConcurrencyError |
Se produjo un error de concurrencia al modificar datos persistidos. |
ConnectionError |
El cliente no pudo conectarse con el servidor backend. |
HttpError |
Se produjo un error al ejecutar la operación HTTP mediante HttpClient. |
Error |
Se produjo un error de aplicación. |
Unauthorized |
Semántica HTTP 401: el usuario no está autorizado para realizar la operación. |
Forbidden |
HTTP 403: El servidor rechazó autorizar la petición. |
NotFound |
HTTP 404: El recurso solicitado no fue encontrado. |
Conflict |
HTTP 409: La petición entra en conflicto con el estado actual. |
UnprocessableEntity |
HTTP 422: La petición es estructuralmente válida, pero semánticamente inválida. |
TooManyRequests |
HTTP 429: Límite de peticiones excedido. |
ServiceUnavailable |
HTTP 503: El servidor no está disponible temporalmente. |
ChangePassword |
El usuario debe cambiar obligatoriamente la contraseña antes de continuar usando la aplicación. |
Exception |
El backend informa explícitamente de que se produjo una excepción durante la ejecución. |
None |
No se realizó ninguna operación. No debería devolverse normalmente en un flujo completado de la aplicación. |
Uso
1. Define tu Servicio
Hereda de RestServiceBase e inyecta IHttpClientFactory (recomendado) o pasa un HttpClient directamente.
public interface IProductService
{
Task<ResultEntities<Product>> GetAllAsync(CancellationToken ct = default);
Task<ResultPaged<Product>> GetPagedAsync(int page, int pageSize, CancellationToken ct = default);
Task<ResultEntity<Product>> GetByIdAsync(int id, CancellationToken ct = default);
Task<ResultEntity<Product>> CreateAsync(Product product, CancellationToken ct = default);
Task<ResultEntity<Product>> UpdateAsync(int id, Product product, CancellationToken ct = default);
Task<ResultEntity<Product>> PatchAsync(int id, object patch, CancellationToken ct = default);
Task<Result> DeleteAsync(int id, CancellationToken ct = default);
Task<Result> DeactivateAsync(int id, string reason, CancellationToken ct = default);
Task<ResultEntity<Stream>> GetExportStreamAsync(int id, CancellationToken ct = default);
}
public class ProductService : RestServiceBase, IProductService
{
// Recomendado: usar IHttpClientFactory para gestión correcta de conexiones y logging
public ProductService(IHttpClientFactory factory, ILogger<ProductService> logger)
: base(factory, nameof(ProductService), logger) { }
public Task<ResultEntities<Product>> GetAllAsync(CancellationToken ct = default)
=> GetsAsync<Product>("api/products", ct);
public Task<ResultPaged<Product>> GetPagedAsync(int page, int pageSize, CancellationToken ct = default)
=> GetPagedAsync<Product>("api/products", new PagingParameters { Page = page, PageSize = pageSize }, ct);
public Task<ResultEntity<Product>> GetByIdAsync(int id, CancellationToken ct = default)
=> GetAsync<Product>($"api/products/{id}", ct);
public Task<ResultEntity<Product>> CreateAsync(Product product, CancellationToken ct = default)
=> PostAsync("api/products", product, ct);
public Task<ResultEntity<Product>> UpdateAsync(int id, Product product, CancellationToken ct = default)
=> PutAsync($"api/products/{id}", product, ct);
public Task<ResultEntity<Product>> PatchAsync(int id, object patch, CancellationToken ct = default)
=> base.PatchAsync<object, Product>($"api/products/{id}", patch, ct);
public Task<Result> DeleteAsync(int id, CancellationToken ct = default)
=> base.DeleteAsync($"api/products/{id}", ct);
public Task<Result> DeactivateAsync(int id, string reason, CancellationToken ct = default)
=> base.DeleteAsync($"api/products/{id}", new { Reason = reason }, ct);
public Task<ResultEntity<Stream>> GetExportStreamAsync(int id, CancellationToken ct = default)
=> DownloadStreamAsync($"api/products/{id}/export", ct);
}
Nota: el reintento automático se habilita para peticiones cuyo cuerpo puede recrearse de forma segura por el pipeline del cliente. Las operaciones que reciben instancias externas de
HttpContentoMultipartFormDataContentno se reintentan automáticamente, porque esos cuerpos pueden depender de streams no reproducibles.
2. Registro en Inyección de Dependencias
Usa los métodos de extensión AddRestService en Program.cs (Blazor / MAUI) o App.axaml.cs (Avalonia):
// Opción A — Simple: URL base + opciones JSON opcionales
builder.Services.AddRestService<IProductService, ProductService>(
baseUrl: "https://api.example.com/",
jsonSerializerOptions: new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Opción B — RestServiceOptions: objeto de configuración completo
builder.Services.AddRestService<IProductService, ProductService>(new RestServiceOptions
{
BaseAddress = "https://api.example.com/",
JsonSerializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true },
TokenProvider = new MyTokenProvider(),
DefaultHeaders = new Dictionary<string, IEnumerable<string>>
{
{ "X-Api-Version", new[] { "2" } },
{ "X-Tenant-Id", new[] { "acme", "tenant-backup" } }
},
Timeout = TimeSpan.FromSeconds(30),
HttpVersion = HttpVersion.Version20,
HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
Behavior = new RestServiceBehaviorOptions
{
EnableAutomaticRetries = true,
EnableTokenRefreshRetry = true,
NormalizeEmptySuccessToOk = true,
PreserveHttpStatusCodeAsResultCode = true,
TreatTimeoutAsHttpError = true,
MaxTransientRetryAttempts = 3
}
});
// Opción C — Sin interfaz (solo tipo concreto)
builder.Services.AddRestService<ProductService>("https://api.example.com/");
// Opción C.1 — Sin interfaz usando RestServiceOptions
builder.Services.AddRestService<ProductService>(new RestServiceOptions
{
BaseAddress = "https://api.example.com/",
Timeout = TimeSpan.FromSeconds(20),
HttpVersion = HttpVersion.Version20,
HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
Behavior = new RestServiceBehaviorOptions
{
EnableAutomaticRetries = true
}
});
// Opción C.2 — Lifetime personalizado
builder.Services.AddRestService<IProductService, ProductService>(
"https://api.example.com/",
serviceLifetime: ServiceLifetime.Singleton);
// Opción D — IHttpClientFactory manual (control total)
builder.Services.AddHttpClient(nameof(ProductService), client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
builder.Services.AddScoped<IProductService, ProductService>();
3. Consumir el Servicio
// En un componente Blazor, ViewModel de MAUI o ViewModel de Avalonia:
var result = await _productService.GetPagedAsync(page: 1, pageSize: 20);
if (result.Status == StatusResult.Ok)
{
// result.Entities → IReadOnlyList<Product>
// result.Page → página actual
// result.PageSize
// result.TotalCount
Console.WriteLine($"Página {result.Page} — {result.TotalCount} productos en total");
}
4. Inicio rápido por plataforma
Usa estas referencias rápidas según el tipo de aplicación:
| Plataforma | Punto típico de registro | Punto típico de consumo | Recomendaciones |
|---|---|---|---|
| Blazor WebAssembly / Server | Program.cs |
Componente, servicio o ViewModel | Usa ITokenProvider con almacenamiento local o estado de autenticación. Trata ConnectionError y HttpError como fallos recuperables de UI. |
| MAUI | MauiProgram.cs |
ViewModel o capa de servicios | Guarda tokens en SecureStorage. Usa CancelOperation para cancelación iniciada por el usuario y HttpError para timeouts o abortos de comunicación. |
| Avalonia UI | App.axaml.cs o raíz de composición |
ViewModel o capa de servicios | Prefiere servicios tipados por área funcional. Muestra ValidationError en formularios y Warning como feedback no bloqueante. |
| WPF | App.xaml.cs / bootstrapper DI |
ViewModel o servicio de aplicación | Mantén el uso de RestServiceBase en infraestructura/servicios, no directamente en las vistas. Usa CancellationTokenSource para operaciones cancelables por el usuario. |
| One Platform | Bootstrap o raíz de composición de la plataforma | Servicio funcional o modelo de pantalla | Conserva la semántica de StatusResult del backend y adapta la presentación solo en la capa UI. |
Ejemplos mínimos:
// Blazor / WPF / Avalonia / One Platform
builder.Services.AddRestService<IProductService, ProductService>("https://api.example.com/");
// MAUI
builder.Services.AddRestService<IProductService, ProductService>(new RestServiceOptions
{
BaseAddress = "https://api.example.com/",
TokenProvider = new MauiTokenProvider()
});
5. Guía rápida de presentación de estados en UI
Para aplicaciones UI, esta tabla suele funcionar bien como punto de partida:
StatusResult |
Tratamiento típico en UI |
|---|---|
Ok, Added, Updated, Deleted |
Continuar normalmente, refrescar UI o mostrar feedback de éxito si aplica |
NoExist |
Renderizar estado vacío, “no disponible” o flujo de “el registro ya no existe” |
Warning |
Mostrar feedback no bloqueante, por ejemplo banner o toast |
ValidationError |
Mostrar result.Errors en formulario o campo |
UnprocessableEntity |
Mostrar feedback de regla de negocio, normalmente a nivel de acción o formulario |
ConnectionError |
Mostrar mensaje de conectividad y ofrecer reintento |
HttpError |
Mostrar mensaje genérico de fallo de comunicación o timeout |
Unauthorized |
Redirigir a login o refrescar contexto de autenticación |
Forbidden |
Mostrar acceso denegado |
CancelOperation |
Normalmente no mostrar error; el usuario o llamador canceló intencionadamente |
Exception, Error, DatabaseError, DataError |
Mostrar error bloqueante o mensaje de fallback y registrar telemetría |
Autenticación — Bearer Token (ITokenProvider)
Implementa ITokenProvider para suministrar tokens desde cualquier fuente (en memoria, SecureStorage en MAUI, AuthenticationStateProvider en Blazor, etc.).
// 1. Implementar ITokenProvider
public class MyTokenProvider : ITokenProvider
{
private string? _token;
public void SetToken(string? token) => _token = token;
public Task<string?> GetTokenAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(_token);
}
RestServiceBase llama automáticamente a GetTokenAsync antes de cada petición y establece la cabecera Authorization: Bearer {token}. Si el token es null o vacío, la cabecera se elimina.
// 2. Registrar en DI
builder.Services.AddSingleton<ITokenProvider, MyTokenProvider>();
builder.Services.AddRestService<IProductService, ProductService>(new RestServiceOptions
{
BaseAddress = "https://api.example.com/",
TokenProvider = serviceProvider.GetRequiredService<ITokenProvider>()
});
Blazor WASM — Integración con AuthenticationStateProvider
public class BlazorTokenProvider : ITokenProvider
{
private readonly ILocalStorageService _localStorage;
public BlazorTokenProvider(ILocalStorageService localStorage)
=> _localStorage = localStorage;
public async Task<string?> GetTokenAsync(CancellationToken cancellationToken = default)
=> await _localStorage.GetItemAsStringAsync("access_token");
}
MAUI — Integración con SecureStorage
public class MauiTokenProvider : ITokenProvider
{
public async Task<string?> GetTokenAsync(CancellationToken cancellationToken = default)
=> await SecureStorage.Default.GetAsync("access_token");
}
Refresco de Token / Reintento 401 (TryRefreshTokenAsync)
Cuando el servidor devuelve HTTP 401, RestServiceBase invoca TryRefreshTokenAsync. Si devuelve true, la petición original se reintenta automáticamente una vez con el nuevo token.
public class ProductService : RestServiceBase, IProductService
{
private readonly IAuthService _authService;
public ProductService(IHttpClientFactory factory, IAuthService authService)
: base(factory, nameof(ProductService))
{
_authService = authService;
}
protected override async Task<bool> TryRefreshTokenAsync(CancellationToken cancellationToken = default)
{
var refreshed = await _authService.RefreshTokenAsync(cancellationToken);
return refreshed; // true → reintenta la petición original; false → propaga Unauthorized
}
}
Reintento ante Errores Transitorios (GetRetryDelayAsync)
Cuando el servidor devuelve 429 Too Many Requests o 503 Service Unavailable, RestServiceBase invoca GetRetryDelayAsync. Devuelve un TimeSpan para esperar y reintentar, o null para no reintentar.
public class ProductService : RestServiceBase, IProductService
{
protected override Task<TimeSpan?> GetRetryDelayAsync(
IResult result,
int attemptNumber,
CancellationToken cancellationToken = default)
{
// Reintentar hasta 3 veces con back-off exponencial
if (attemptNumber > 3)
return Task.FromResult<TimeSpan?>(null);
var delay = TimeSpan.FromSeconds(Math.Pow(2, attemptNumber)); // 2s, 4s, 8s
return Task.FromResult<TimeSpan?>(delay);
}
}
Nota:
GetRetryDelayAsyncaplica tanto enSendAsynccomo enDownloadAsync. El parámetroattemptNumbercomienza en1en el primer reintento. En operaciones que usanHttpContent/MultipartFormDataContentproporcionado externamente, el reintento automático queda deshabilitado de forma intencionada salvo que el propio método recree el cuerpo de la petición.
Resultados Paginados (GetPagedAsync)
GetPagedAsync devuelve un ResultPaged<T> con metadatos de paginación.
// Con PagingParameters (Page + PageSize)
public Task<ResultPaged<Product>> GetPagedAsync(int page, int pageSize, CancellationToken ct = default)
=> GetPagedAsync<Product>("api/products", new PagingParameters { Page = page, PageSize = pageSize }, ct);
// Con IParameters (filtro personalizado + paginación)
public Task<ResultPaged<Product>> SearchAsync(ProductParameters parameters, CancellationToken ct = default)
=> GetPagedAsync<Product>("api/products", parameters, ct);
// Sin parámetros (query string ya embebida en el path)
public Task<ResultPaged<Product>> GetPagedRawAsync(CancellationToken ct = default)
=> GetPagedAsync<Product>("api/products?page=1&pageSize=10", ct);
Nota:
PagingParametersse serializa como?Page=1&PageSize=20en la query string. Las clasesIParameterspersonalizadas deben implementarGetParameters()devolviendoIEnumerable<KeyValuePair<string, string>>.
Subida de Archivos (UploadAsync)
UploadAsync acepta MultipartFormDataContent y lo envía tal cual se proporciona. Como los cuerpos multipart pueden incluir streams no reproducibles, estas sobrecargas no se reintentan automáticamente tras 401, 429 o 503.
public async Task<ResultEntity<UploadResponse>> UploadAvatarAsync(Stream fileStream, string fileName, CancellationToken ct = default)
{
var content = new MultipartFormDataContent();
content.Add(new StreamContent(fileStream), "file", fileName);
return await UploadAsync<UploadResponse>("api/users/avatar", content, ct);
}
Descarga de Archivos con Progreso (DownloadAsync)
public async Task<ResultEntity<byte[]>> DownloadReportAsync(
int reportId,
IProgress<long>? progress = null,
CancellationToken ct = default)
{
return await DownloadAsync($"api/reports/{reportId}/download", progress, ct);
}
// Uso — ej. en un ViewModel de MAUI / Avalonia:
var progress = new Progress<long>(bytesRead => Console.WriteLine($"Descargados: {bytesRead} bytes"));
var result = await _reportService.DownloadReportAsync(42, progress, cancellationToken);
if (result.Status == StatusResult.Ok)
{
await File.WriteAllBytesAsync("informe.pdf", result.Entity!, cancellationToken);
}
PATCH — Actualización Parcial (PatchAsync)
Usa PatchAsync para enviar actualizaciones parciales de entidades sin reemplazar el recurso completo.
// Mismo tipo de entrada y salida
public Task<ResultEntity<Product>> PatchNameAsync(int id, string newName, CancellationToken ct = default)
=> PatchAsync($"api/products/{id}", new { Name = newName }, ct);
// Tipos diferentes de entrada y salida
public Task<ResultEntity<ProductDetail>> PatchStatusAsync(int id, StatusPatch patch, CancellationToken ct = default)
=> PatchAsync<StatusPatch, ProductDetail>($"api/products/{id}", patch, ct);
// Tipo de resultado personalizado
public Task<MyCustomResult> PatchWithCustomResultAsync(int id, object patch, MyCustomResult initial, CancellationToken ct = default)
=> PatchAsync($"api/products/{id}", patch, initial, ct);
Manejo de Errores de Conexión
RestServiceBase distingue fallos de transporte/cliente frente a resultados funcionales del backend. Cuando el backend ya devuelve un Result, ese StatusResult funcional se conserva. Cuando la petición falla antes de obtener una respuesta funcional satisfactoria, estos escenarios cliente se manejan automáticamente:
| Escenario | StatusResult |
Causa |
|---|---|---|
| Cancelado por el llamador | CancelOperation |
OperationCanceledException con el CancellationToken proporcionado cancelado |
| Timeout / operación HTTP abortada | HttpError |
OperationCanceledException sin cancelación explícita del llamador |
| Sin red / conexión rechazada | ConnectionError |
HttpRequestException + SocketException |
| Error cliente o de comunicación HTTP | HttpError |
Cualquier otra Exception |
var result = await _productService.GetAllAsync();
switch (result.Status)
{
case StatusResult.Ok:
// Usar los datos del resultado
break;
case StatusResult.ConnectionError:
// Mostrar banner sin conexión / botón de reintento
break;
case StatusResult.Unauthorized:
// Redirigir al login
break;
case StatusResult.ValidationError:
// Mostrar result.Errors al usuario
break;
case StatusResult.CancelOperation:
// Petición cancelada — no se requiere acción
break;
}
En respuestas satisfactorias, ResultCode conserva el código HTTP cuando existe. Esto incluye respuestas vacías correctas como 200 OK sin cuerpo o 204 No Content. Cuando no se puede deserializar un payload Result desde una respuesta satisfactoria, RestServiceBase normaliza el estado final a Ok.
Metadatos del Resultado
Pitasoft.Client conserva los metadatos conocidos de Pitasoft.Result desde ambas fuentes:
- payloads JSON aplanados producidos por los conversores de
Pitasoft.Result - headers HTTP reales como
Location,ETag,Retry-After,Content-Location,Last-ModifiedyCache-Control
Cuando el mismo metadato conocido aparece tanto en el payload del resultado como en los headers HTTP, prevalece el valor del header HTTP porque representa la respuesta de transporte real recibida por el cliente. Los metadatos conocidos de respuestas HTTP fallidas se aplican antes de ejecutar los hooks On*, de modo que sobreescrituras como OnTooManyRequests pueden inspeccionar Retry-After directamente.
var result = await _productService.GetAsync(42, ct);
if (result.Metadata?.TryGetValue("ETag", out var etag) == true)
{
// Usar el ETag para cache o concurrencia optimista
}
if (result.Metadata?.TryGetValue("Location", out var location) == true)
{
// Seguir la URI del recurso creado
}
if (result.Metadata?.TryGetValue("Retry-After", out var retryAfter) == true)
{
// Mostrar sugerencia de reintento en UI o programar el siguiente intento
}
Interoperabilidad Con Pitasoft.Result.AspNetCore
Si el backend usa Pitasoft.Result.AspNetCore, mantén alineada la configuración de serialización con los conversores de resultados del servidor.
Baseline recomendado:
- usar
ResultJsonSerializerOptions.Create()al crear una configuración de serialización dedicada paraPitasoft.Client - o enriquecer una instancia existente de
JsonSerializerOptionsconResultJsonSerializerOptions.Add(options)
Esto es especialmente importante para:
ResultEntities<T>ResultPaged<T>- payloads estructurados de
ErrorCollection
Al consumir el comportamiento por defecto del adaptador ASP.NET Core, recuerda también:
NoExistsuele devolverse como200 OKNotFoundse reserva para404explícitoDeletedsuele viajar como204 No Content;Pitasoft.Clientpreserva esa semántica comoStatusResult.Deleteden operacionesDELETE
using Pitasoft.Result.AspNetCore;
var serializerOptions = ResultJsonSerializerOptions.Create();
var options = new RestServiceOptions
{
BaseAddress = "https://api.example.com/",
JsonSerializerOptions = serializerOptions
};
Cabeceras Predeterminadas y Timeout (RestServiceOptions)
var options = new RestServiceOptions
{
BaseAddress = "https://api.example.com/",
Timeout = TimeSpan.FromSeconds(15),
HttpVersion = HttpVersion.Version20,
HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
DefaultHeaders = new Dictionary<string, IEnumerable<string>>
{
{ "X-Api-Key", new[] { "tu-api-key" } },
{ "X-Api-Version", new[] { "2" } },
{ "X-Tenant-Id", new[] { "acme", "tenant-backup" } }
}
};
// Pasar directamente al constructor de RestServiceBase:
public class ProductService : RestServiceBase
{
public ProductService(RestServiceOptions options) : base(options) { }
}
Opciones de Comportamiento (RestServiceBehaviorOptions)
Usa RestServiceOptions.Behavior para controlar reintentos, tratamiento de timeout, preservación de ResultCode y normalización de respuestas satisfactorias vacías.
var options = new RestServiceOptions
{
BaseAddress = "https://api.example.com/",
Behavior = new RestServiceBehaviorOptions
{
EnableAutomaticRetries = true,
EnableTokenRefreshRetry = true,
NormalizeEmptySuccessToOk = true,
PreserveHttpStatusCodeAsResultCode = true,
TreatTimeoutAsHttpError = true,
MaxTransientRetryAttempts = 3
}
};
| Opción | Valor por defecto | Propósito |
|---|---|---|
EnableAutomaticRetries |
true |
Habilita reintentos transitorios en peticiones replay-safe |
EnableTokenRefreshRetry |
true |
Permite que un 401 dispare refresh de token y un único reintento |
NormalizeEmptySuccessToOk |
true |
Convierte respuestas satisfactorias vacías desde None a Ok |
PreserveHttpStatusCodeAsResultCode |
true |
Conserva el código HTTP en ResultCode cuando existe una respuesta HTTP real |
TreatTimeoutAsHttpError |
true |
Trata los OperationCanceledException por timeout como HttpError salvo cancelación explícita del llamador |
MaxTransientRetryAttempts |
null |
Límite opcional para reintentos transitorios replay-safe |
Helpers de Consulta (QueryHelpers)
QueryHelpers proporciona métodos estáticos para componer query strings de forma segura. Todos los valores se codifican en URL automáticamente.
// Clave-valor simple
var uri = QueryHelpers.AddQueryString("api/products", "category", "electronics");
// → "api/products?category=electronics"
// Diccionario
var uri = QueryHelpers.AddQueryString("api/products", new Dictionary<string, string>
{
{ "category", "electronics" },
{ "inStock", "true" }
});
// → "api/products?category=electronics&inStock=true"
// IParameters (ej. PagingParameters o EntityParameters personalizados)
var uri = QueryHelpers.AddQueryString("api/products", new PagingParameters { Page = 2, PageSize = 10 });
// → "api/products?Page=2&PageSize=10"
// Conserva query string y anclas existentes
var uri = QueryHelpers.AddQueryString("api/products?sort=asc#section", "page", "3");
// → "api/products?sort=asc&page=3#section"
Hooks Virtuales (Sobrescribir en Clases Derivadas)
RestServiceBase expone métodos virtuales protegidos para personalizar el comportamiento sin reimplementar el pipeline completo de peticiones. Los hooks ligados a 401/403/404/409/429/503 reaccionan a semántica HTTP, mientras que las respuestas satisfactorias conservan cualquier StatusResult funcional devuelto por el backend:
| Método | Cuándo se invoca | Por defecto |
|---|---|---|
OnUnauthorized(IResult) |
HTTP 401 recibido y no reintentado | sin acción |
OnForbidden(IResult) |
HTTP 403 recibido | sin acción |
OnNotFound(IResult) |
HTTP 404 recibido | sin acción |
OnConflict(IResult) |
HTTP 409 recibido | sin acción |
OnTooManyRequests(IResult) |
HTTP 429 recibido | sin acción |
OnServiceUnavailable(IResult) |
HTTP 503 recibido | sin acción |
OnError(IResult) |
Ruta de error de aplicación/infraestructura, incluyendo fallos HTTP/cliente genéricos | sin acción |
OnChangePassword(IResult) |
El servidor señaliza StatusResult.ChangePassword |
sin acción |
HandleSatisfactoryAsync(...) |
Tras una respuesta HTTP exitosa | Enruta a los hooks On* correspondientes |
HandleErrorsAsync(...) |
Tras una respuesta HTTP fallida | Establece Status, parsea payloads de error 400/422, aplica metadatos conocidos e invoca hooks On* |
TryRefreshTokenAsync(...) |
En HTTP 401 antes de propagarlo | Devuelve false |
GetRetryDelayAsync(IResult, int, CancellationToken) |
En error transitorio (429/503) | Devuelve null (sin reintento) |
public class ProductService : RestServiceBase
{
protected override void OnUnauthorized(IResult result)
{
// ej. lanzar un evento, navegar al login, limpiar estado local
}
protected override void OnForbidden(IResult result)
{
// ej. mostrar mensaje "acceso denegado"
}
protected override void OnNotFound(IResult result)
{
// ej. navegar a una página 404
}
protected override void OnError(IResult result)
{
// ej. registrar en Sentry, mostrar una notificación toast
}
protected override async Task<bool> TryRefreshTokenAsync(CancellationToken cancellationToken = default)
{
// Devuelve true si el token fue refrescado y la petición debe reintentarse
return await _authService.RefreshAsync(cancellationToken);
}
protected override Task<TimeSpan?> GetRetryDelayAsync(IResult result, int attemptNumber, CancellationToken cancellationToken = default)
{
// Devuelve un delay para reintentar, o null para detener
return Task.FromResult(attemptNumber <= 3 ? TimeSpan.FromSeconds(Math.Pow(2, attemptNumber)) : (TimeSpan?)null);
}
}
Todos los Constructores Disponibles
RestServiceBase soporta siete patrones de construcción:
// 1. Solo dirección base
protected RestServiceBase(string baseAddress, ILogger? logger = null)
// 2. Dirección base + opciones JSON
protected RestServiceBase(string baseAddress, JsonSerializerOptions jsonSerializerOptions, ILogger? logger = null)
// 3. HttpClient existente
protected RestServiceBase(HttpClient client, ILogger? logger = null)
// 4. HttpClient existente + opciones JSON
protected RestServiceBase(HttpClient client, JsonSerializerOptions jsonSerializerOptions, ILogger? logger = null)
// 5. IHttpClientFactory + nombre de cliente (recomendado para DI)
protected RestServiceBase(IHttpClientFactory httpClientFactory, string clientName, ILogger? logger = null)
// 6. IHttpClientFactory + nombre de cliente + opciones JSON
protected RestServiceBase(IHttpClientFactory httpClientFactory, string clientName, JsonSerializerOptions jsonSerializerOptions, ILogger? logger = null)
// 7. RestServiceOptions (configuración completa: dirección base, JSON, token, cabeceras, timeout, versión HTTP y behavior)
protected RestServiceBase(RestServiceOptions options, ILogger? logger = null)
Superficie Pública
RestServiceBase es la abstracción base pública del paquete. Los consumidores deberían trabajar con interfaces tipadas del dominio, como IProductService, en lugar de depender de un contrato genérico IRestService.
HttpClient no se expone como parte del contrato público del servicio. Las clases derivadas siguen pudiendo usarlo internamente desde RestServiceBase, pero los consumidores deberían utilizar métodos tipados del servicio en lugar de hacer llamadas HTTP crudas.
Los detalles de infraestructura como ILogger, ITokenProvider, JsonSerializerOptions y RestServiceBehaviorOptions siguen siendo aspectos de configuración e implementación. Las clases derivadas pueden utilizarlos, pero no forman parte del contrato del servicio en el que deberían apoyarse los consumidores.
Cuando registras un servicio con RestServiceOptions, esas opciones se pasan a esa instancia del servicio durante su construcción. No se registran como un singleton compartido de RestServiceOptions en el contenedor.
ITokenProvider
public interface ITokenProvider
{
Task<string?> GetTokenAsync(CancellationToken cancellationToken = default);
}
Autor
Sebastián Martínez Pérez
Licencia
Copyright © 2026 Pitasoft, S.L.
Licenciado bajo los términos de la LICENSE.txt incluida en este repositorio.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.Http (>= 10.0.6)
- Pitasoft.Result (>= 7.3.2)
-
net8.0
- Microsoft.Extensions.Http (>= 10.0.6)
- Pitasoft.Result (>= 7.3.2)
-
net9.0
- Microsoft.Extensions.Http (>= 10.0.6)
- Pitasoft.Result (>= 7.3.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 8.1.2 | 92 | 4/17/2026 |
| 8.1.1 | 121 | 3/26/2026 |
| 8.0.1 | 105 | 3/24/2026 |
| 7.0.7 | 102 | 3/20/2026 |
| 7.0.6 | 107 | 3/13/2026 |
| 7.0.5 | 107 | 3/2/2026 |
| 7.0.4 | 106 | 2/25/2026 |
| 7.0.3 | 121 | 2/12/2026 |
| 7.0.2 | 124 | 1/26/2026 |
| 7.0.1 | 131 | 1/26/2026 |
| 6.5.5 | 332 | 5/27/2025 |
| 6.5.4 | 307 | 5/27/2025 |
| 6.5.3 | 301 | 5/25/2025 |
| 6.5.2 | 312 | 5/25/2025 |
| 6.5.1 | 261 | 5/23/2025 |
| 6.5.0 | 315 | 5/19/2025 |
| 6.0.0 | 347 | 8/21/2024 |
| 5.2.2 | 347 | 5/17/2024 |
| 5.2.1 | 315 | 5/17/2024 |
| 5.2.0 | 437 | 11/20/2023 |