DNN · Cliente C# completo
Este es el NeuroonClient que el recipe DNN end-to-end construye paso a paso, presentado aquí como artefacto único listo para copiar al módulo. Compatible con DNN 9.x sobre .NET Framework 4.7.2+ (con System.Text.Json 6.0+) o .NET 8.
Dependencias NuGet
<PackageReference Include="Polly" Version="8.*" />
<PackageReference Include="System.Net.Http.Json" Version="6.*" />
<PackageReference Include="System.Text.Json" Version="6.*" />
App_Code/Neuroon/NeuroonClient.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using Polly;
using Polly.Retry;
public sealed class NeuroonClient
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _http;
private readonly string _shopId;
private readonly AsyncRetryPolicy<HttpResponseMessage> _retry = Policy
.HandleResult<HttpResponseMessage>(r => (int)r.StatusCode == 429 || (int)r.StatusCode >= 500)
.Or<HttpRequestException>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
public NeuroonClient()
{
_http = new HttpClient
{
BaseAddress = new Uri(NeuroonSettings.ApiUrl),
Timeout = TimeSpan.FromSeconds(45)
};
_http.DefaultRequestHeaders.Add("X-Shop-API-Key", NeuroonSettings.ApiKey);
_http.DefaultRequestHeaders.Add("User-Agent", "DNN-Neuroon/1.0");
_shopId = NeuroonSettings.ShopId;
}
public async Task<SyncResult> SyncProductsAsync(SyncMode mode, IEnumerable<NeuroonProduct> products)
{
var aggregate = new SyncResult();
foreach (var batch in Chunk(products, 500))
{
var resp = await _retry.ExecuteAsync(() => _http.PostAsJsonAsync(
$"/api/plugin/shops/{_shopId}/products/sync",
new { syncType = mode.ToString(), products = batch },
JsonOpts));
resp.EnsureSuccessStatusCode();
aggregate.Merge(await resp.Content.ReadFromJsonAsync<SyncResult>(JsonOpts));
}
return aggregate;
}
public async Task TrackConversionAsync(string orderId, decimal orderValue, string currency,
IEnumerable<ConversionLine> lines)
{
var resp = await _retry.ExecuteAsync(() => _http.PostAsJsonAsync(
$"/api/plugin/shops/{_shopId}/track/conversion",
new { orderId, orderValue, currency, conversions = lines },
JsonOpts));
resp.EnsureSuccessStatusCode();
}
public async Task<string> IssueWidgetTokenAsync()
{
var resp = await _retry.ExecuteAsync(() => _http.PostAsync(
$"/api/shops/{_shopId}/widget-token", content: null));
resp.EnsureSuccessStatusCode();
var payload = await resp.Content.ReadFromJsonAsync<WidgetTokenResponse>(JsonOpts);
return payload.Token;
}
public async Task<ShopInfo> GetShopInfoAsync()
{
var resp = await _retry.ExecuteAsync(() => _http.GetAsync("/api/plugin/shops/me"));
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadFromJsonAsync<ShopInfo>(JsonOpts);
}
private static IEnumerable<List<T>> Chunk<T>(IEnumerable<T> source, int size)
{
var bucket = new List<T>(size);
foreach (var item in source)
{
bucket.Add(item);
if (bucket.Count == size) { yield return bucket; bucket = new List<T>(size); }
}
if (bucket.Count > 0) yield return bucket;
}
public enum SyncMode { INCREMENTAL, FULL }
public sealed class SyncResult
{
public int TotalReceived { get; set; }
public int NewProducts { get; set; }
public int UpdatedProducts { get; set; }
public int Skipped { get; set; }
public int Failed { get; set; }
public List<SyncError> Errors { get; set; } = new();
public int ProductsCount { get; set; }
public int RemainingProducts { get; set; }
public void Merge(SyncResult other)
{
TotalReceived += other.TotalReceived;
NewProducts += other.NewProducts;
UpdatedProducts += other.UpdatedProducts;
Skipped += other.Skipped;
Failed += other.Failed;
Errors.AddRange(other.Errors ?? Enumerable.Empty<SyncError>());
ProductsCount = other.ProductsCount;
RemainingProducts = other.RemainingProducts;
}
}
public sealed class SyncError
{
public string ExternalId { get; set; }
public string Error { get; set; }
}
public sealed class ConversionLine
{
public string ProductId { get; set; }
public string SearchLogId { get; set; } // requerido por la API (@NotBlank)
public int Quantity { get; set; }
public decimal LineTotal { get; set; }
}
public sealed class WidgetTokenResponse
{
public string Token { get; set; }
}
public sealed class ShopInfo
{
public string Id { get; set; }
public string Name { get; set; }
public string Plan { get; set; }
public int MaxProducts { get; set; }
public int ProductsCount { get; set; }
public int MaxSearchesPerMonth { get; set; }
public int SearchesThisMonth { get; set; }
}
}
Notas de diseño
HttpClientper-instance: en DNN no se reutiliza unHttpClientglobal tan trivialmente como en ASP.NET Core (no hayIHttpClientFactory). Una nueva instancia por scheduler run es aceptable porque el scheduler no se ejecuta en tight-loop.AsyncRetryPolicyúnica: cubre429,5xxyHttpRequestException. Backoff2^nsegundos con 3 reintentos =2 + 4 + 8 = 14 smáximo. Suficiente para absorber ventanas cortas de rate limit.JsonNamingPolicy.CamelCase: el backend exige claves encamelCase(syncType,externalId, etc.).WhenWritingNullevita enviar campos opcionales vacíos.User-Agentespecífico: facilita el filtrado en logs del backend al diagnosticar incidentes.Timeout: 45 s: cubre batches de 500 productos en p99 sin colgar el AppPool.
Cómo usarlo
// Sync
var client = new NeuroonClient();
var result = await client.SyncProductsAsync(NeuroonClient.SyncMode.INCREMENTAL, products);
// Conversion
await client.TrackConversionAsync("ORD-1", 99.99m, "EUR", new[] {
new NeuroonClient.ConversionLine { ProductId = "SKU-1", SearchLogId = "log_aaa", Quantity = 1, LineTotal = 99.99m }
});
// Widget token
string token = await client.IssueWidgetTokenAsync();
// Shop info / quotas
var info = await client.GetShopInfoAsync();
Console.WriteLine($"Plan: {info.Plan}, products: {info.ProductsCount}/{info.MaxProducts}");
Tests
Mantén una suite mínima con WireMock.Net que valide:
SyncProductsAsynctrocea en lotes de 500 cuando le pasas 1.200 productos._retryreintenta 3 veces sobre429y luego rinde.IssueWidgetTokenAsyncparsea correctamente{ "token": "..." }(el response no incluyeexpiresAt; el TTL es fijo a 24 h).JsonNamingPolicyemitesyncType(noSyncType) en el body.
Próximos pasos
scheduled-task— clase scheduler que llama aSyncProductsAsync.razor-snippets— embebido del widget y cart bridge.- Recipe · DNN end-to-end — guía completa.