OneSchema.AspNetCore 0.7.1

dotnet add package OneSchema.AspNetCore --version 0.7.1
                    
NuGet\Install-Package OneSchema.AspNetCore -Version 0.7.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="OneSchema.AspNetCore" Version="0.7.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="OneSchema.AspNetCore" Version="0.7.1" />
                    
Directory.Packages.props
<PackageReference Include="OneSchema.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add OneSchema.AspNetCore --version 0.7.1
                    
#r "nuget: OneSchema.AspNetCore, 0.7.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package OneSchema.AspNetCore@0.7.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=OneSchema.AspNetCore&version=0.7.1
                    
Install as a Cake Addin
#tool nuget:?package=OneSchema.AspNetCore&version=0.7.1
                    
Install as a Cake Tool

OneSchema.AspNetCore

NuGet

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:

  1. Register your configuration with AddOneSchemaJwtValidation.
  2. 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();
  1. 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.7.1 23 8/3/2026
0.7.0 35 8/3/2026
0.6.0 38 8/3/2026
0.5.0 49 7/31/2026
0.4.0 44 7/31/2026
0.3.0 120 4/30/2026
0.2.0 111 4/30/2026
0.1.0 126 3/30/2026