OneSchema.AspNetCore
0.7.1
dotnet add package OneSchema.AspNetCore --version 0.7.1
NuGet\Install-Package OneSchema.AspNetCore -Version 0.7.1
<PackageReference Include="OneSchema.AspNetCore" Version="0.7.1" />
<PackageVersion Include="OneSchema.AspNetCore" Version="0.7.1" />
<PackageReference Include="OneSchema.AspNetCore" />
paket add OneSchema.AspNetCore --version 0.7.1
#r "nuget: OneSchema.AspNetCore, 0.7.1"
#:package OneSchema.AspNetCore@0.7.1
#addin nuget:?package=OneSchema.AspNetCore&version=0.7.1
#tool nuget:?package=OneSchema.AspNetCore&version=0.7.1
OneSchema.AspNetCore
ASP.NET Core integration for OneSchema. Provides minimal-API endpoint mapping for validation webhooks, strongly-typed row models, optional JWT request validation, helpers for batch lookups against your data store, an API client for retrieving imported rows, batched import handlers for processing completed embed sessions, and an in-memory client fake for tests.
Requires .NET 10+
Install
dotnet add package OneSchema.AspNetCore
Quick Start
The minimal setup is outlined below:
using OneSchema.AspNetCore.Authentication;
using OneSchema.AspNetCore.Validation;
var builder = WebApplication.CreateBuilder(args);
// 1. Register your handlers
builder.Services.AddValidationHookHandler<ContactValidationHandler, ContactRow>();
builder.Services.AddValidationHookHandler<ProductUniquenessHandler, ProductRow>();
// 2. (Optional) Configure JWT validation
builder.Services.AddOneSchemaJwtValidation(opts =>
{
opts.ClientId = builder.Configuration["OneSchema:ClientId"]!;
opts.ClientSecret = builder.Configuration["OneSchema:ClientSecret"]!;
});
var app = builder.Build();
// 3. Map endpoints (one per handler)
app.MapValidationHook<ContactValidationHandler, ContactRow>("/webhooks/validate-contacts")
.RequireOneSchemaJwtValidation();
app.MapValidationHook<ProductUniquenessHandler, ProductRow>("/webhooks/validate-products");
// 4. (Optional) Mint embed tokens for your frontend as { jwt, clientId }.
app.MapOneSchemaEmbedToken("/validation/jwt", httpContext => new OneSchemaEmbedTokenRequest
{
UserId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)!,
AdditionalClaims = new Dictionary<string, object>
{
["org-id"] = httpContext.User.FindFirstValue("org-id")!,
},
}).RequireAuthorization();
app.Run();
Each handler is bound to its own URL. Configure that URL in the OneSchema dashboard as a validation hook for the matching template.
Handlers
Handlers come in both dynamic and strongly typed flavors: BatchValidationHookHandler
exposes each row's values as a dictionary, while BatchValidationHookHandler<TRow> maps
them directly to the template columns. You'll receive every row in the webhook batch and a
ValidationResultBuilder to attach errors/warnings against specific rows.
public class ContactValidationHandler : BatchValidationHookHandler<ContactRow>
{
protected override Task ValidateAsync(
ValidationHookRequest<ContactRow> request,
ValidationResultBuilder results,
CancellationToken cancellationToken)
{
foreach (var row in request.Rows)
{
if (string.IsNullOrWhiteSpace(row.Values.Email))
results.ForRow(row).Error("email", "Email is required.");
else if (!row.Values.Email.Contains('@'))
results.ForRow(row).Warning("email", "Email appears invalid.")
.WithSuggestion("user@example.com");
}
return Task.CompletedTask;
}
}
Because the whole batch is available, checks that look across rows or query an external system (such as a DB) need no extra machinery:
public class ProductUniquenessHandler : BatchValidationHookHandler<ProductRow>
{
protected override Task ValidateAsync(
ValidationHookRequest<ProductRow> request,
ValidationResultBuilder results,
CancellationToken cancellationToken)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in request.Rows)
{
if (string.IsNullOrWhiteSpace(row.Values.Sku))
{
results.ForRow(row).Error("sku", "SKU is required.");
continue;
}
if (!seen.Add(row.Values.Sku))
results.ForRow(row).Error("sku", "Duplicate SKU.");
}
return Task.CompletedTask;
}
}
Defining a Row Model
To define a strongly typed model, map your OneSchema template columns to a POCO using JsonPropertyName:
public class ContactRow
{
[JsonPropertyName("first_name")] public string? FirstName { get; set; }
[JsonPropertyName("last_name")] public string? LastName { get; set; }
[JsonPropertyName("email")] public string? Email { get; set; }
[JsonPropertyName("phone")] public string? Phone { get; set; }
}
Database Lookups - RowIndex helper
For bulk checks involving database queries, use IndexBy to build a key → rows
index, issue a single batch query, then pass the matching values back to ErrorForMatches (or WarningForMatches).
public class ContactEmailExistsHandler : BatchValidationHookHandler<ContactRow>
{
protected override async Task ValidateAsync(
ValidationHookRequest<ContactRow> request,
ValidationResultBuilder results,
CancellationToken cancellationToken)
{
var byEmail = request.Rows
.Where(r => !string.IsNullOrWhiteSpace(r.Values.Email))
.IndexBy(v => v.Email!, StringComparer.OrdinalIgnoreCase);
var taken = await db.Users
.Where(u => byEmail.Keys.Contains(u.Email))
.Select(u => u.Email)
.ToListAsync(cancellationToken);
byEmail.ErrorForMatches(
taken,
results,
column: "email",
messageFactory: email => $"'{email}' is already registered.");
}
}
JWT Validation
OneSchema signs webhook requests with an embed_user_jwt field in the request body.
If present, the JWT claims are available on the webhook request via context.Request.Identity.
To enforce JWT validation:
- Register your configuration with
AddOneSchemaJwtValidation. - Chain
.RequireOneSchemaJwtValidation()on each endpoint that should enforce it.
builder.Services.AddOneSchemaJwtValidation(opts =>
{
opts.ClientId = builder.Configuration["OneSchema:ClientId"]!;
opts.ClientSecret = builder.Configuration["OneSchema:ClientSecret"]!;
});
app.MapValidationHook<ContactValidationHandler, ContactRow>("/webhooks/validate-contacts")
.RequireOneSchemaJwtValidation();
- Generate a JWT to use in your frontend.
Endpoints without RequireOneSchemaJwtValidation() accept unsigned requests.
Invalid or missing tokens produce 401 Unauthorized.
Embed Token Endpoint
MapOneSchemaEmbedToken maps a GET endpoint that returns { "jwt": "...", "clientId": "..." } —
everything a frontend needs to boot the importer, with the client id kept as backend-only
configuration. You supply the user id and any additional claims from the current request, and
chain your own auth requirements onto the returned builder:
app.MapOneSchemaEmbedToken("/validation/jwt", httpContext => new OneSchemaEmbedTokenRequest
{
UserId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)!,
AdditionalClaims = new Dictionary<string, object>
{
["org-id"] = httpContext.User.FindFirstValue("org-id")!,
},
}).RequireAuthorization();
For a custom response shape, inject the OneSchemaJwtContext singleton and call
GenerateEmbedToken(userId, additionalClaims) directly.
OneSchema API Client
The library includes a typed client for the OneSchema REST API, wrapping the Get Imported Rows and Get Imported File URL endpoints. Register it with your API key from the OneSchema dashboard:
using OneSchema.AspNetCore.Client;
builder.Services.AddOneSchemaApiClient(opts =>
{
opts.ApiKey = builder.Configuration["OneSchema:ApiKey"]!;
// Defaults to the US region; EU and Canada are also available:
// opts.BaseAddress = OneSchemaApiOptions.EuropeBaseAddress;
});
Inject IOneSchemaApiClient to fetch imported rows for an embed session. Records can be
mapped to the same POCO row models used by validation hooks, or accessed as dictionaries:
app.MapGet("/imports/{embedId:int}/rows", async (int embedId, IOneSchemaApiClient client) =>
{
// Strongly typed page fetch
var page = await client.GetImportedRowsAsync<ContactRow>(embedId, startRow: 0, count: 100);
Console.WriteLine($"{page.Count} rows from {page.SheetMetadata.OriginalFileName}");
// Or untyped, one dictionary per record keyed by template column key
var untyped = await client.GetImportedRowsAsync(embedId);
return page.Records;
});
For larger imports, the streaming variants page through all rows automatically:
await foreach (var contact in client.StreamImportedRowsAsync<ContactRow>(embedId, pageSize: 1000))
{
// process each row
}
Non-success responses throw HttpRequestException with the StatusCode populated.
AddOneSchemaApiClient returns an IHttpClientBuilder, so resilience or custom handlers
can be chained, e.g. .AddStandardResilienceHandler().
Streaming the Imported File
For very large imports, paging the rows endpoint means one API request per page. The imported-file variants instead make a fixed three requests — column metadata, a presigned URL (valid for 15 minutes), and the CSV download — and stream rows out of the file as it parses:
await foreach (var contact in client.StreamImportedFileRowsAsync<ContactRow>(embedId))
{
// process each row
}
// Or fetch the URL alone and handle the download yourself (e.g. save to a temp file first):
var url = await client.GetImportedFileUrlAsync(embedId, ImportedFileRowFilter.Clean);
Rows come back identical to the imported-rows endpoint: one record per row keyed by template
column key, with empty cells as null. The file's raw headers are template column display
names — the streaming variants remap them to template column keys via the column metadata, so
only raw GetImportedFileUrlAsync downloads see display-name headers. The presigned file is
downloaded without the API key header. ImportedFileRowFilter selects all rows (default), only
clean rows, or only rows with validation errors. CSV parsing is backed by
Sep.
Importing Rows with Handlers
To process a completed embed session server-side (e.g. with the importer configured as
importConfig: { type: "local", metadataOnly: true }), implement OneSchemaImportHandler<TRow>.
The base class owns the mechanical loop — streaming rows via the API client, normalizing them
through PrepareRow, buffering into batches of BatchSize, and aggregating progress — so an
implementation only decides how a batch is persisted:
public class ContactImportHandler(AppDbContext db) : OneSchemaImportHandler<ContactRow>
{
public override string TemplateKey => "contacts";
// Return null to skip a row; skipped rows count toward progress.
protected override ContactRow? PrepareRow(ContactRow row)
=> string.IsNullOrWhiteSpace(row.Email) ? null : row;
// Optional: wrap the whole import in one transaction. Per-batch writes stay visible
// inside it, and disposing on an exception rolls the entire import back.
public override async Task<OneSchemaImportProgress> ImportAsync(
OneSchemaImportContext context, CancellationToken cancellationToken = default)
{
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
var progress = await base.ImportAsync(context, cancellationToken);
await transaction.CommitAsync(cancellationToken);
return progress;
}
protected override async Task<OneSchemaImportBatchResult> ProcessBatchAsync(
IReadOnlyList<ContactRow> batch, OneSchemaImportContext context, CancellationToken cancellationToken)
{
// Dedupe against the database, insert the rest, save, and report the split:
// return new OneSchemaImportBatchResult { Imported = added, Skipped = duplicates };
}
}
Register handlers with AddOneSchemaImportHandler, then dispatch by template key through
IOneSchemaImporter:
builder.Services.AddOneSchemaImportHandler<ContactImportHandler>();
app.MapPost("/imports/{embedId:int}", async (
int embedId, string templateKey, IOneSchemaImporter importer, CancellationToken cancellationToken) =>
{
if (!importer.SupportsTemplate(templateKey))
return Results.BadRequest($"Unknown template key '{templateKey}'.");
var progress = await importer.ImportAsync(templateKey, embedId, cancellationToken: cancellationToken);
return Results.Ok(progress);
});
ImportAsync also accepts an optional callback invoked with running totals after each
successful batch — useful for persisting progress that a status endpoint can report. Running
large imports in the background (job persistence, queues, progress streaming) is the host
application's responsibility; the handler seam composes with whichever scheduler or bus you
already use.
By default a handler streams the imported CSV file — a fixed three requests, regardless of row count. Handlers can switch back to paging the imported-rows endpoint by overriding the row source:
protected override IAsyncEnumerable<ContactRow> StreamRowsAsync(
OneSchemaImportContext context, CancellationToken cancellationToken)
=> context.Client.StreamImportedRowsAsync<ContactRow>(
context.EmbedId, BatchSize, cancellationToken);
Testing with FakeOneSchemaApiClient
OneSchema.AspNetCore.Testing.FakeOneSchemaApiClient is an in-memory IOneSchemaApiClient
with the same paging, streaming, and serialization semantics as the real client. Seed rows per
embed id and substitute it for the real client in your test host:
var fake = new FakeOneSchemaApiClient();
fake.SetRows(1234,
new Dictionary<string, string?> { ["first_name"] = "Jane", ["email"] = "jane@example.com" });
// Or seed from typed records:
fake.SetRows(1234, new ContactRow { FirstName = "Jane", Email = "jane@example.com" });
services.RemoveAll<IOneSchemaApiClient>();
services.AddSingleton<IOneSchemaApiClient>(fake);
Webhook Submission
The ExportWebhookRequest type is included for convenience for handling webhook exports, but no specific abstraction is provided for working with this type. To pull validated data via the API instead, use the OneSchema API Client.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.IdentityModel.JsonWebTokens (>= 8.22.0)
- Sep (>= 0.15.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.