Pitasoft.Client 8.1.2

dotnet add package Pitasoft.Client --version 8.1.2
                    
NuGet\Install-Package Pitasoft.Client -Version 8.1.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Pitasoft.Client" Version="8.1.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Pitasoft.Client" Version="8.1.2" />
                    
Directory.Packages.props
<PackageReference Include="Pitasoft.Client" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Pitasoft.Client --version 8.1.2
                    
#r "nuget: Pitasoft.Client, 8.1.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Pitasoft.Client@8.1.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Pitasoft.Client&version=8.1.2
                    
Install as a Cake Addin
#tool nuget:?package=Pitasoft.Client&version=8.1.2
                    
Install as a Cake Tool

Pitasoft.Client

NuGet Version NuGet Downloads License .NET Build Status

English | Castellano


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.

  • SettingRestService has been renamed to RestServiceOptions.
  • IRestService has been removed. Consumers should depend on typed service interfaces such as IProductService, or directly on the concrete service when appropriate.
  • RestServiceBase no longer exposes infrastructure members as part of the public API. HttpClient, ILogger, ITokenProvider, JsonSerializerOptions, and RestServiceBehaviorOptions are now encapsulated and available only to derived services.
  • AddRestService(...) now supports configurable ServiceLifetime, and implementation-only registration also supports RestServiceOptions.

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: RestServiceBase provides methods for GET, POST, PUT, PATCH, and DELETE operations, including batch processing and body-based deletions.
  • Logging Support: Built-in integration with ILogger for 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 StatusResult contracts 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.Result metadata when it arrives either through flattened JSON result payloads or through known HTTP headers such as Location, ETag, and Retry-After.
  • Paginated Results: Built-in support for ResultPaged<T> via GetPagedAsync, with PagingParameters and IParameters overloads, and IsEmpty convenience property.
  • Bearer Token Authentication: Automatic injection of Authorization: Bearer headers via the ITokenProvider interface.
  • Token Refresh / 401 Retry: Override TryRefreshTokenAsync to implement refresh token logic with automatic request retry for replay-safe requests.
  • Transient Error Retry: Override GetRetryDelayAsync to implement custom retry-delay strategies for transient errors (TooManyRequests, ServiceUnavailable) on replay-safe requests.
  • File Upload: UploadAsync sends MultipartFormDataContent for uploading files and form data. Supports both ResultEntity<T> and Result (no-body) overloads.
  • File Download with Progress: DownloadAsync streams binary content with optional IProgress<long> reporting.
  • Connection Error Detection: Distinguishes network-level errors (SocketException) from generic HTTP errors using StatusResult.ConnectionError.
  • Modern HttpClient Management: Full support for IHttpClientFactory for efficient connection management, socket pooling, and DI integration.
  • DI Extensions: AddRestService extension methods for registering services fluently in Blazor, MAUI, and Avalonia Program.cs / MauiProgram.cs.
  • Flexible Configuration: RestServiceOptions centralizes base URL, JSON options, token provider, default headers, request timeout, HTTP version settings, and behavior options.
  • Query Helpers: QueryHelpers safely 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.Result for consistent and expressive response handling.

Performance Optimizations (.NET 8/9/10)

  • Zero-allocation URI parsing: ReadOnlySpan<char> and AsSpan() avoid unnecessary string allocations during query string manipulation.
  • Fast character search: SearchValues<char> enables ultra-fast detection of ? and # control characters.
  • Memory efficiency: StringBuilder pre-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:

  • Status
  • Errors
  • ResultCode
  • CalculationTime
  • Metadata (IHasMetadata), projected by Pitasoft.Result JSON converters through flattened top-level fields such as location, etag, retryAfter, contentLocation, lastModified, and cacheControl
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 HttpContent or MultipartFormDataContent instances 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: GetRetryDelayAsync applies to SendAsync and DownloadAsync. The attemptNumber parameter starts at 1 on the first retry. For operations using externally supplied HttpContent / 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: PagingParameters serializes as ?Page=1&PageSize=20 in the query string. Custom IParameters classes must implement GetParameters() returning IEnumerable<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.Result converters
  • real HTTP headers such as Location, ETag, Retry-After, Content-Location, Last-Modified, and Cache-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 dedicated Pitasoft.Client serializer configuration
  • or enrich an existing JsonSerializerOptions instance with ResultJsonSerializerOptions.Add(options)

This is especially important for:

  • ResultEntities<T>
  • ResultPaged<T>
  • structured ErrorCollection payloads

When consuming the default ASP.NET Core adapter behavior, also keep in mind:

  • NoExist is typically returned as 200 OK
  • NotFound is reserved for explicit 404
  • Deleted commonly travels as 204 No Content; Pitasoft.Client preserves that semantic as StatusResult.Deleted on DELETE operations
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.

  • SettingRestService pasa a llamarse RestServiceOptions.
  • IRestService ha sido eliminada. Los consumidores deberían depender de interfaces tipadas del dominio, como IProductService, o directamente del servicio concreto cuando tenga sentido.
  • RestServiceBase deja de exponer miembros de infraestructura como parte de la API pública. HttpClient, ILogger, ITokenProvider, JsonSerializerOptions y RestServiceBehaviorOptions quedan encapsulados y solo están disponibles para clases derivadas.
  • AddRestService(...) ahora permite configurar ServiceLifetime, y el registro solo por implementación también soporta RestServiceOptions.

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: RestServiceBase proporciona 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 ILogger para 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 StatusResult devueltos 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.Result cuando llegan tanto en payloads JSON aplanados como en headers HTTP conocidos como Location, ETag y Retry-After.
  • Resultados Paginados: Soporte nativo de ResultPaged<T> mediante GetPagedAsync, con sobrecargas para PagingParameters e IParameters, y propiedad de conveniencia IsEmpty.
  • Autenticación con Bearer Token: Inyección automática de cabeceras Authorization: Bearer a través de la interfaz ITokenProvider.
  • Refresco de Token / Reintento 401: Sobrescribe TryRefreshTokenAsync para implementar lógica de refresh token con reintento automático en peticiones seguras de reproducir.
  • Reintento ante Errores Transitorios: Sobrescribe GetRetryDelayAsync para implementar estrategias de reintento personalizadas ante errores transitorios (TooManyRequests, ServiceUnavailable) en peticiones seguras de reproducir.
  • Subida de Archivos: UploadAsync envía MultipartFormDataContent para subir ficheros y formularios. Soporta sobrecargas con ResultEntity<T> y Result (sin cuerpo de respuesta).
  • Descarga de Archivos con Progreso: DownloadAsync descarga contenido binario con reporte opcional mediante IProgress<long>.
  • Detección de Error de Conexión: Distingue errores de red (SocketException) de errores HTTP genéricos usando StatusResult.ConnectionError.
  • Gestión Moderna de HttpClient: Soporte completo para IHttpClientFactory con gestión eficiente de conexiones, pooling de sockets e integración con DI.
  • Extensiones de DI: Métodos de extensión AddRestService para registrar servicios de forma fluida en Program.cs / MauiProgram.cs de Blazor, MAUI y Avalonia.
  • Configuración Flexible: RestServiceOptions centraliza URL base, opciones JSON, proveedor de token, cabeceras predeterminadas, timeout, versión HTTP y opciones de comportamiento.
  • Helpers de Consulta: QueryHelpers construye query strings de forma segura y eficiente desde diccionarios, IParameters o 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.Result para un manejo de respuestas consistente y expresivo.

Optimizaciones de Rendimiento (.NET 8/9/10)

  • Análisis de URIs sin asignaciones: ReadOnlySpan<char> y AsSpan() 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 StringBuilder y 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:

  • Status
  • Errors
  • ResultCode
  • CalculationTime
  • Metadata (IHasMetadata), proyectado por los conversores JSON de Pitasoft.Result mediante campos de primer nivel como location, etag, retryAfter, contentLocation, lastModified y cacheControl
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 HttpContent o MultipartFormDataContent no 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: GetRetryDelayAsync aplica tanto en SendAsync como en DownloadAsync. El parámetro attemptNumber comienza en 1 en el primer reintento. En operaciones que usan HttpContent / MultipartFormDataContent proporcionado 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: PagingParameters se serializa como ?Page=1&PageSize=20 en la query string. Las clases IParameters personalizadas deben implementar GetParameters() devolviendo IEnumerable<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-Modified y Cache-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 para Pitasoft.Client
  • o enriquecer una instancia existente de JsonSerializerOptions con ResultJsonSerializerOptions.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:

  • NoExist suele devolverse como 200 OK
  • NotFound se reserva para 404 explícito
  • Deleted suele viajar como 204 No Content; Pitasoft.Client preserva esa semántica como StatusResult.Deleted en operaciones DELETE
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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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
Loading failed