Skip to main content

DNN · Full C# client

This is the NeuroonClient that the DNN end-to-end recipe builds step by step, presented here as a single artifact ready to copy into the module. Compatible with DNN 9.x on .NET Framework 4.7.2+ (with System.Text.Json 6.0+) or .NET 8.

NuGet dependencies

<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; } // required by the 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; }
}
}

Design notes

  • Per-instance HttpClient: in DNN you cannot reuse a global HttpClient as trivially as in ASP.NET Core (no IHttpClientFactory). A new instance per scheduler run is acceptable because the scheduler does not run in tight-loop.
  • Single AsyncRetryPolicy: covers 429, 5xx and HttpRequestException. 2^n second backoff with 3 retries = 2 + 4 + 8 = 14 s max. Enough to absorb short rate-limit windows.
  • JsonNamingPolicy.CamelCase: the backend requires camelCase keys (syncType, externalId, etc.). WhenWritingNull avoids sending empty optional fields.
  • Specific User-Agent: makes log filtering easier on the backend when diagnosing incidents.
  • Timeout: 45 s: covers 500-product batches at p99 without hanging the AppPool.

How to use it

// 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

Keep a minimal WireMock.Net suite that validates:

  1. SyncProductsAsync chunks into batches of 500 when given 1,200 products.
  2. _retry retries 3 times on 429 and then surrenders.
  3. IssueWidgetTokenAsync parses { "token": "..." } correctly (the response does not include expiresAt; TTL is fixed at 24 h).
  4. JsonNamingPolicy emits syncType (not SyncType) in the body.

Next steps