Saltar al contenido principal

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

  • HttpClient per-instance: en DNN no se reutiliza un HttpClient global tan trivialmente como en ASP.NET Core (no hay IHttpClientFactory). Una nueva instancia por scheduler run es aceptable porque el scheduler no se ejecuta en tight-loop.
  • AsyncRetryPolicy única: cubre 429, 5xx y HttpRequestException. Backoff 2^n segundos con 3 reintentos = 2 + 4 + 8 = 14 s máximo. Suficiente para absorber ventanas cortas de rate limit.
  • JsonNamingPolicy.CamelCase: el backend exige claves en camelCase (syncType, externalId, etc.). WhenWritingNull evita enviar campos opcionales vacíos.
  • User-Agent especí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:

  1. SyncProductsAsync trocea en lotes de 500 cuando le pasas 1.200 productos.
  2. _retry reintenta 3 veces sobre 429 y luego rinde.
  3. IssueWidgetTokenAsync parsea correctamente { "token": "..." } (el response no incluye expiresAt; el TTL es fijo a 24 h).
  4. JsonNamingPolicy emite syncType (no SyncType) en el body.

Próximos pasos