Skip to main content

C# / .NET Data Provider

This guide shows how to build a GW data provider using ASP.NET Core minimal APIs. The implementation uses dependency injection for services and inline middleware for API-key validation.

Project Setup

dotnet new web -n DataProvider
cd DataProvider
dotnet add package AWSSDK.S3
DataProvider.csproj (key packages)
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.*" />
</ItemGroup>

Models

Query Envelope

Models/QueryEnvelope.cs
using System.Text.Json.Serialization;

namespace DataProvider.Models;

public record QueryEnvelope
{
[JsonPropertyName("queryId")]
public string QueryId { get; init; } = "";

[JsonPropertyName("datasetId")]
public string? DatasetId { get; init; }

[JsonPropertyName("endpoint")]
public string? Endpoint { get; init; }

[JsonPropertyName("parameters")]
public Dictionary<string, object>? Parameters { get; init; }

[JsonPropertyName("delivery")]
public DeliveryConfig? Delivery { get; init; }

[JsonPropertyName("callbackUrl")]
public string CallbackUrl { get; init; } = "";

[JsonPropertyName("callbackToken")]
public string CallbackToken { get; init; } = "";
}

public record DeliveryConfig
{
[JsonPropertyName("mechanism")]
public string Mechanism { get; init; } = "inline";

[JsonPropertyName("url")]
public string? Url { get; init; }

[JsonPropertyName("s3Bucket")]
public string? S3Bucket { get; init; }

[JsonPropertyName("s3KeyPrefix")]
public string? S3KeyPrefix { get; init; }
}

Callback Payload

Models/CallbackPayload.cs
using System.Text.Json.Serialization;

namespace DataProvider.Models;

public class CallbackPayload
{
[JsonPropertyName("queryId")]
public string QueryId { get; set; } = "";

[JsonPropertyName("status")]
public string Status { get; set; } = "";

[JsonPropertyName("recordCount")]
public int RecordCount { get; set; }

[JsonPropertyName("executionTimeMs")]
public long ExecutionTimeMs { get; set; }

[JsonPropertyName("error")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; set; }
}

Delivery Service

Route results to S3, a webhook, or inline:

Services/DeliveryService.cs
using System.Text.Json;
using Amazon.Runtime;
using Amazon.S3;
using Amazon.S3.Model;
using DataProvider.Models;

namespace DataProvider.Services;

public class DeliveryService
{
public record DeliveryResult(string Mechanism, string? Reference = null, int? StatusCode = null);

private readonly string _s3Endpoint;
private readonly string _s3Bucket;
private readonly HttpClient _http;
private readonly ILogger<DeliveryService> _logger;

public DeliveryService(HttpClient http, ILogger<DeliveryService> logger)
{
_http = http;
_logger = logger;
_s3Endpoint = Environment.GetEnvironmentVariable("S3_ENDPOINT") ?? "http://localstack:4566";
_s3Bucket = Environment.GetEnvironmentVariable("S3_BUCKET") ?? "gw-deliveries";
}

public async Task<DeliveryResult> DeliverResultsAsync(QueryEnvelope envelope, object results)
{
var mechanism = envelope.Delivery?.Mechanism ?? "inline";

return mechanism switch
{
"gw_s3_sync" => await DeliverToS3Async(envelope, results),
"webhook" => await DeliverToWebhookAsync(envelope, results),
_ => new DeliveryResult("inline"),
};
}

private async Task<DeliveryResult> DeliverToS3Async(QueryEnvelope envelope, object results)
{
var bucket = envelope.Delivery?.S3Bucket ?? _s3Bucket;
var prefix = envelope.Delivery?.S3KeyPrefix ?? "";
var key = $"{prefix}{envelope.QueryId}.json";

var s3Config = new AmazonS3Config
{
ServiceURL = _s3Endpoint,
ForcePathStyle = true
};
var credentials = new BasicAWSCredentials("test", "test");

using var s3Client = new AmazonS3Client(credentials, s3Config);

var json = JsonSerializer.Serialize(results);
await s3Client.PutObjectAsync(new PutObjectRequest
{
BucketName = bucket,
Key = key,
ContentBody = json,
ContentType = "application/json"
});

_logger.LogInformation("[delivery] S3 upload: s3://{Bucket}/{Key}", bucket, key);
return new DeliveryResult("gw_s3_sync", $"s3://{bucket}/{key}");
}

private async Task<DeliveryResult> DeliverToWebhookAsync(QueryEnvelope envelope, object results)
{
var url = envelope.Delivery?.Url
?? throw new InvalidOperationException("Webhook delivery requires delivery.url");

var json = JsonSerializer.Serialize(results);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");

var response = await _http.PostAsync(url, content);

_logger.LogInformation("[delivery] Webhook POST to {Url} returned {StatusCode}",
url, (int)response.StatusCode);
return new DeliveryResult("webhook", null, (int)response.StatusCode);
}
}

Callback Service

HMAC-SHA256 signing and callback posting:

Services/CallbackService.cs
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using DataProvider.Models;

namespace DataProvider.Services;

public class CallbackService
{
private readonly HttpClient _http;
private readonly ILogger<CallbackService> _logger;

public CallbackService(HttpClient http, ILogger<CallbackService> logger)
{
_http = http;
_logger = logger;
}

public async Task PostCallbackAsync(
string callbackUrl, string callbackToken, CallbackPayload payload)
{
try
{
var body = JsonSerializer.SerializeToUtf8Bytes(payload);

using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(callbackToken));
var signatureBytes = hmac.ComputeHash(body);
var signature = Convert.ToHexStringLower(signatureBytes);

var request = new HttpRequestMessage(HttpMethod.Post, callbackUrl)
{
Content = new ByteArrayContent(body)
};
request.Content.Headers.ContentType = new("application/json");
request.Headers.Add("X-GW-Signature", $"sha256={signature}");

await _http.SendAsync(request);
}
catch (Exception ex)
{
// Log but don't throw -- GW will retry or poll
_logger.LogError("[callback] Failed for query {QueryId}: {Error}",
payload.QueryId, ex.Message);
}
}
}

Application Entry Point

Wire everything together using minimal APIs:

Program.cs
using System.Diagnostics;
using DataProvider.Models;
using DataProvider.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient<CallbackService>();
builder.Services.AddHttpClient<DeliveryService>();
builder.Services.AddSingleton<MockDataService>();

var app = builder.Build();

var gwApiKey = Environment.GetEnvironmentVariable("GW_API_KEY") ?? "demo-api-key";

// API-key validation middleware
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/health"))
{
await next();
return;
}

var apiKey = context.Request.Headers["X-GW-Api-Key"].FirstOrDefault();
if (apiKey != gwApiKey)
{
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(
new { error = "Invalid or missing X-GW-Api-Key header" });
return;
}

await next();
});

// Health check
app.MapGet("/health", () =>
Results.Ok(new { status = "ok", service = "data-provider-csharp" }));

// Company search -- middleware pattern
app.MapPost("/api/search/companies", async (
QueryEnvelope envelope,
MockDataService mockData,
CallbackService callback,
DeliveryService delivery) =>
{
var sw = Stopwatch.StartNew();

if (string.IsNullOrEmpty(envelope.QueryId) || string.IsNullOrEmpty(envelope.CallbackUrl))
return Results.BadRequest(new { error = "Missing required envelope fields" });

try
{
var name = envelope.Parameters?.GetValueOrDefault("name")?.ToString();
var country = envelope.Parameters?.GetValueOrDefault("country")?.ToString();

var results = mockData.SearchCompanies(name, country);
var executionTimeMs = sw.ElapsedMilliseconds;

var deliveryResult = await delivery.DeliverResultsAsync(envelope, results);

await callback.PostCallbackAsync(
envelope.CallbackUrl, envelope.CallbackToken,
new CallbackPayload
{
QueryId = envelope.QueryId,
Status = "delivered",
RecordCount = results.Count,
ExecutionTimeMs = executionTimeMs
});

if (deliveryResult.Mechanism == "inline")
return Results.Ok(new {
queryId = envelope.QueryId, recordCount = results.Count,
executionTimeMs, results });
return Results.Ok(new {
queryId = envelope.QueryId, recordCount = results.Count,
executionTimeMs,
delivery = new { mechanism = deliveryResult.Mechanism,
reference = deliveryResult.Reference } });
}
catch (Exception ex)
{
await callback.PostCallbackAsync(
envelope.CallbackUrl, envelope.CallbackToken,
new CallbackPayload
{
QueryId = envelope.QueryId,
Status = "failed",
ExecutionTimeMs = sw.ElapsedMilliseconds,
Error = ex.Message
});

return Results.Json(
new { error = ex.Message, queryId = envelope.QueryId },
statusCode: 500);
}
});

// Sanctions check -- manual pattern
app.MapPost("/api/search/sanctions", async (
QueryEnvelope envelope,
MockDataService mockData,
CallbackService callback,
DeliveryService delivery) =>
{
var sw = Stopwatch.StartNew();

// Step 1: Validate envelope
if (string.IsNullOrEmpty(envelope.QueryId) || string.IsNullOrEmpty(envelope.CallbackUrl))
return Results.BadRequest(new { error = "Missing required envelope fields" });

// Step 2: Validate parameters
var name = envelope.Parameters?.GetValueOrDefault("name")?.ToString();
if (string.IsNullOrEmpty(name))
return Results.BadRequest(new {
error = "parameters.name is required for sanctions checks",
queryId = envelope.QueryId });

var country = envelope.Parameters?.GetValueOrDefault("country")?.ToString();

// Step 3: Run business logic
var results = mockData.CheckSanctions(name, country);
var executionTimeMs = sw.ElapsedMilliseconds;

try
{
// Step 4: Deliver results
var deliveryResult = await delivery.DeliverResultsAsync(envelope, results);

// Step 5: Post callback
await callback.PostCallbackAsync(
envelope.CallbackUrl, envelope.CallbackToken,
new CallbackPayload
{
QueryId = envelope.QueryId,
Status = "delivered",
RecordCount = results.Count,
ExecutionTimeMs = executionTimeMs
});

if (deliveryResult.Mechanism == "inline")
return Results.Ok(new {
queryId = envelope.QueryId, recordCount = results.Count,
executionTimeMs, results });
return Results.Ok(new {
queryId = envelope.QueryId, recordCount = results.Count,
executionTimeMs,
delivery = new { mechanism = deliveryResult.Mechanism,
reference = deliveryResult.Reference } });
}
catch (Exception ex)
{
await callback.PostCallbackAsync(
envelope.CallbackUrl, envelope.CallbackToken,
new CallbackPayload
{
QueryId = envelope.QueryId,
Status = "failed",
ExecutionTimeMs = sw.ElapsedMilliseconds,
Error = ex.Message
});

return Results.Json(
new { error = ex.Message, queryId = envelope.QueryId },
statusCode: 500);
}
});

app.Run();

Running the Provider

dotnet run

Or with environment variables:

GW_API_KEY=my-key S3_ENDPOINT=http://localhost:4566 dotnet run

Testing Locally

curl -X POST http://localhost:5000/api/search/sanctions \
-H "Content-Type: application/json" \
-H "X-GW-Api-Key: demo-api-key" \
-d '{
"queryId": "test-001",
"datasetId": "ds-sanctions",
"endpoint": "/api/search/sanctions",
"parameters": { "name": "Smith" },
"delivery": { "mechanism": "inline" },
"callbackUrl": "http://localhost:8083/webhook",
"callbackToken": "test-secret"
}'