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
| Metodo | Endpoint |
|---|---|
| Token interno, con cache e rinnovo su 401 | POST /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.