Passa al contenuto principale

C# / .NET

Requisiti: .NET 8 o superiore, nessuna dipendenza esterna.

Avvio rapido​

$env:ECUBE_BASE_URL = "https://api.e-cube.it"
$env:ECUBE_USERNAME = "<USERNAME>"
$env:ECUBE_PASSWORD = "<PASSWORD>"
dotnet run --project samples/dotnet

Il programma d'esempio esegue solo letture.

Cosa fa EcubeClient​

MetodoEndpoint
Token interno, con cache e rinnovo su 401POST /Token
ReadAllAsync(resource, recordSelection)GET /api/resources/{R} paginato
ReadChangesAsync(resource, lastUtcRun)GET /api/sync/{R}?LastUtcRun=
QueryAsync(resource, filters, sort)POST /api/resources/{R}/query
SyncAsync(resource, records, batchSize)POST /api/sync/{R} a blocchi
GetStringAsync(url)Qualsiasi GET autenticata

Retry con attesa crescente su 408, 429, 5xx ed errori di rete; un solo rinnovo del token su 401; EcubeException con codice e corpo della risposta.

Programma d'esempio​

samples/dotnet/Program.cs
// Esempio in SOLA LETTURA: dotnet run --project samples/dotnet
// Variabili d'ambiente: ECUBE_BASE_URL (facoltativa), ECUBE_USERNAME, ECUBE_PASSWORD
using Ecube;

using var ecube = EcubeClient.FromEnvironment();

// 1. Prime 5 famiglie articoli
var n = 0;
await foreach (var f in ecube.ReadAllAsync("Famiglia", pageSize: 5))
{
Console.WriteLine($"{f.GetProperty("cod_famiglia")} {f.GetProperty("des_famiglia")}");
if (++n == 5) break;
}

// 2. Documenti di un anno dalla vista MCP (solo la prima pagina)
var anno = DateTime.UtcNow.Year;
var json = await ecube.GetStringAsync(
"api/resources/MCPDocumentiT?PageSize=1&PageNumber=1&RecordSelection="
+ Uri.EscapeDataString($"num_anno={anno}"));
Console.WriteLine($"Risposta MCPDocumentiT: {json.Length} caratteri");

// 3. Query strutturata
var q = await ecube.QueryAsync("MCPClienti",
[new QueryFilter("des_ragsoc", "contains", "S")],
[new QuerySort("des_ragsoc")], pageSize: 5);
Console.WriteLine($"Risposta query: {q.Length} caratteri");

Classe EcubeClient​

samples/dotnet/EcubeClient.cs
// EcubeClient — client minimo per le WebAPI E-CUBE (.NET 8+).
// Endpoint usati: POST /Token, GET /api/resources/{R}, POST /api/resources/{R}/query,
// GET /api/sync/{R}?LastUtcRun=…, POST /api/sync/{R}. Vedi https://docs.e-cube.it
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text.Json;

namespace Ecube;

public sealed class EcubeClient : IDisposable
{
private readonly HttpClient _http;
private readonly string _username;
private readonly string _password;
private string? _token;
private DateTimeOffset _tokenExpires;

public EcubeClient(string baseUrl, string username, string password, HttpMessageHandler? handler = null)
{
_http = handler is null ? new HttpClient() : new HttpClient(handler);
_http.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
_http.Timeout = TimeSpan.FromMinutes(5);
_username = username;
_password = password;
}

/// <summary>Crea il client da ECUBE_BASE_URL, ECUBE_USERNAME, ECUBE_PASSWORD.</summary>
public static EcubeClient FromEnvironment() => new(
Environment.GetEnvironmentVariable("ECUBE_BASE_URL") ?? "https://api.e-cube.it",
Environment.GetEnvironmentVariable("ECUBE_USERNAME") ?? throw new InvalidOperationException("ECUBE_USERNAME mancante"),
Environment.GetEnvironmentVariable("ECUBE_PASSWORD") ?? throw new InvalidOperationException("ECUBE_PASSWORD mancante"));

// ---------- autenticazione ----------

private async Task<string> TokenAsync(bool force = false, CancellationToken ct = default)
{
if (!force && _token is not null && DateTimeOffset.UtcNow < _tokenExpires) return _token;
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["username"] = _username,
["password"] = _password,
});
using var resp = await _http.PostAsync("Token", form, ct);
if (!resp.IsSuccessStatusCode)
throw new EcubeException(resp.StatusCode, "Token non ottenuto", await resp.Content.ReadAsStringAsync(ct));
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
_token = doc.RootElement.GetProperty("access_token").GetString()!;
var seconds = doc.RootElement.TryGetProperty("expires_in", out var e) ? e.GetInt32() : 3600;
_tokenExpires = DateTimeOffset.UtcNow.AddSeconds(seconds * 0.9); // margine prima della scadenza
return _token;
}

// ---------- trasporto con rinnovo token e retry ----------

private static bool IsTransient(HttpStatusCode s) =>
s is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || (int)s >= 500;

private async Task<string> SendAsync(Func<HttpRequestMessage> build, CancellationToken ct, int maxAttempts = 4)
{
var refreshed = false;
for (var attempt = 1; ; attempt++)
{
using var req = build();
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await TokenAsync(ct: ct));
HttpResponseMessage? resp = null;
try
{
resp = await _http.SendAsync(req, ct);
var body = await resp.Content.ReadAsStringAsync(ct);
if (resp.IsSuccessStatusCode) return body;
if (resp.StatusCode == HttpStatusCode.Unauthorized && !refreshed)
{
refreshed = true; // 401: un solo rinnovo del token
await TokenAsync(force: true, ct);
attempt--;
continue;
}
if (!IsTransient(resp.StatusCode) || attempt >= maxAttempts)
throw new EcubeException(resp.StatusCode, $"{req.Method} {req.RequestUri}", body);
}
catch (HttpRequestException) when (attempt < maxAttempts) { }
catch (TaskCanceledException) when (!ct.IsCancellationRequested && attempt < maxAttempts) { }
finally { resp?.Dispose(); }
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)) + TimeSpan.FromMilliseconds(Random.Shared.Next(250));
await Task.Delay(delay, ct);
}
}

public Task<string> GetStringAsync(string relativeUrl, CancellationToken ct = default) =>
SendAsync(() => new HttpRequestMessage(HttpMethod.Get, relativeUrl), ct);

// ---------- lettura ----------

/// <summary>GET /api/resources/{resource} pagina per pagina, con filtro RecordSelection facoltativo.</summary>
public async IAsyncEnumerable<JsonElement> ReadAllAsync(string resource, string? recordSelection = null,
int pageSize = 500, [EnumeratorCancellation] CancellationToken ct = default)
{
var filter = recordSelection is null ? "" : $"&RecordSelection={Uri.EscapeDataString(recordSelection)}";
await foreach (var row in PagedAsync(p => $"api/resources/{resource}?PageSize={pageSize}&PageNumber={p}{filter}", pageSize, ct))
yield return row;
}

/// <summary>GET /api/sync/{resource}?LastUtcRun=… : solo i record variati dopo lastUtcRun.</summary>
public async IAsyncEnumerable<JsonElement> ReadChangesAsync(string resource, DateTime lastUtcRun,
int pageSize = 500, [EnumeratorCancellation] CancellationToken ct = default)
{
var since = Uri.EscapeDataString(lastUtcRun.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fff"));
await foreach (var row in PagedAsync(p => $"api/sync/{resource}?LastUtcRun={since}&PageSize={pageSize}&PageNumber={p}", pageSize, ct))
yield return row;
}

private async IAsyncEnumerable<JsonElement> PagedAsync(Func<int, string> url, int pageSize,
[EnumeratorCancellation] CancellationToken ct)
{
for (int page = 1, pages = 1; page <= pages; page++)
{
using var doc = JsonDocument.Parse(await GetStringAsync(url(page), ct));
var rows = doc.RootElement;
if (rows.ValueKind != JsonValueKind.Array || rows.GetArrayLength() == 0) yield break;
if (page == 1 && rows[0].TryGetProperty("TotalRowsCount", out var total))
pages = (int)Math.Ceiling(total.GetInt32() / (double)pageSize);
foreach (var row in rows.EnumerateArray()) yield return row.Clone();
}
}

/// <summary>POST /api/resources/{resource}/query con filtri e ordinamenti strutturati (sola lettura).</summary>
public Task<string> QueryAsync(string resource, IEnumerable<QueryFilter> filters,
IEnumerable<QuerySort>? sort = null, int pageSize = 100, int pageNumber = 1, CancellationToken ct = default)
{
var body = new[]
{
new
{
ResourcesViewsFilters = filters.ToArray(),
ResourcesViewsSortFields = (sort ?? []).ToArray(),
}
};
return SendAsync(() => new HttpRequestMessage(HttpMethod.Post,
$"api/resources/{resource}/query?PageSize={pageSize}&PageNumber={pageNumber}")
{ Content = JsonContent.Create(body, options: JsonOptions) }, ct);
}

// ---------- scrittura ----------

/// <summary>
/// POST /api/sync/{resource} a blocchi. OPERAZIONE DI SCRITTURA: usare solo su un tenant concordato.
/// Si ferma al primo blocco rifiutato (EcubeException con il corpo della risposta).
/// </summary>
public async Task SyncAsync<T>(string resource, IReadOnlyList<T> records, int batchSize = 500,
IProgress<int>? progress = null, CancellationToken ct = default)
{
for (var i = 0; i < records.Count; i += batchSize)
{
var batch = records.Skip(i).Take(batchSize).ToArray();
await SendAsync(() => new HttpRequestMessage(HttpMethod.Post, $"api/sync/{resource}")
{ Content = JsonContent.Create(batch, options: JsonOptions) }, ct, maxAttempts: 3);
progress?.Report(i + batch.Length);
}
}

private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = null };

public void Dispose() => _http.Dispose();
}

public sealed record QueryFilter(string FieldName, string Operator, string Value);

public sealed record QuerySort(string FieldName, string Direction = "asc");

public sealed class EcubeException(HttpStatusCode status, string message, string body)
: Exception($"{message}: HTTP {(int)status} {body}")
{
public HttpStatusCode Status { get; } = status;
public string Body { get; } = body;
}

Uso con dependency injection​

builder.Services.AddSingleton(_ => new EcubeClient(
builder.Configuration["Ecube:BaseUrl"]!,
builder.Configuration["Ecube:Username"]!,
builder.Configuration["Ecube:Password"]!)); // da user-secrets o Key Vault, mai da appsettings in chiaro

Pubblicare record​

var marche = new[] { new { cod_marca = "<CODICE>", des_marca = "<DESCRIZIONE>", flg_isdeleted = false } };
await ecube.SyncAsync("MarcaArticoli", marche);

I nomi delle proprietà sono quelli della guida, in snake_case: il client non applica conversioni di naming.