DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

.NET 10 Data Export API — Minimal APIs, Mapster, Rate Limiting & OpenAPI Done Right

.NET 10 Data Export API — Minimal APIs, Mapster, Rate Limiting & OpenAPI Done Right<br>

.NET 10 Data Export API — Minimal APIs, Mapster, Rate Limiting & OpenAPI Done Right

Most .NET developers can build a CRUD Web API.

Far fewer can design a data export API that is:

  • Safe to expose to admins
  • Fast enough to scan large datasets
  • Mapped to clean DTOs (no leaking internal models)
  • Protected by rate limiting and JWT-based auth
  • Self-documented with OpenAPI

In this post we’ll build a production‑grade Data Export API using:

  • .NET 10 minimal APIs (code works on .NET 8+ with tiny changes)
  • Azure AD / Entra ID JWT Bearer authentication
  • A strict AdminOnly authorization policy
  • Rate limiting per endpoint
  • Mapster for fast, compile‑time friendly mapping
  • Serilog for structured logging
  • Swashbuckle + Microsoft.AspNetCore.OpenApi for Swagger and WithOpenApi()

We’ll also solve a subtle but very common issue:

“RouteHandlerBuilder does not contain a definition for WithOpenApi…”

…which quietly blocks many people following Microsoft docs.

If you can run dotnet new webapi, you can follow this.


Table of Contents

  1. Architecture Mental Model
  2. .csproj: Targeting .NET 10 and Selecting the Right Packages
  3. Program.cs: Minimal API Pipeline for Exports
  4. Authentication & Authorization: Admin-Only Exports
  5. Rate Limiting: Don’t Let Exports Become a DoS Vector
  6. Mapster: Domain Models → Safe Export DTOs
  7. OpenAPI & the WithOpenApi Trap
  8. Putting It All Together: Export & Metadata Endpoints
  9. Production Notes & Hard-Won Lessons

1. Architecture Mental Model

We’re building a Data Export API that lives behind your admin portal.

Mentally, think of three layers:

  1. Domain services

    • IRawDataService, ICallRecordService, etc.
    • Talk to your databases, queues, warehouses, etc.
  2. Export mapping layer

    • Uses Mapster to convert rich domain objects into flat, masked DTOs.
    • This is where you hide internal IDs, mask PII, and generate synthetic IDs.
  3. HTTP façade (Minimal API)

    • Applies auth, rate limiting, shape selection, and OpenAPI metadata.
    • Exposes just enough surface to power CSV/Excel/JSON exports from the admin portal.

The result is an API you’re not afraid to expose to power‑users.


2. .csproj: Targeting .NET 10 and Selecting the Right Packages

Here is a trimmed .csproj for the DataExportApi project:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Mapster" Version="7.4.0" />
    <PackageReference Include="Mapster.DependencyInjection" Version="1.0.0" />
    <PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.RateLimiting" Version="8.0.0" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.13" />
  </ItemGroup>

</Project>
Enter fullscreen mode Exit fullscreen mode

Key decisions:

  • net10.0 — Gives you the latest JIT, runtime perf, and language features. The sample still works on net8.0 if you drop the TFM down.
  • Serilog.AspNetCore — Structured logging that survives production.
  • Microsoft.AspNetCore.Authentication.JwtBearer — Native JWT validation for Entra ID/Azure AD.
  • Microsoft.AspNetCore.RateLimiting — First‑class rate limiting in the ASP.NET Core pipeline.
  • Swashbuckle.AspNetCore + Microsoft.AspNetCore.OpenApi — Required if you want WithOpenApi() on minimal APIs with .NET 8+.

Hard‑to‑find detail:

WithOpenApi() lives in the OpenAPI minimal API metadata extensions package (Microsoft.AspNetCore.OpenApi). If you only install Swashbuckle, you get Swagger UI but no WithOpenApi extension on RouteHandlerBuilder.


3. Program.cs: Minimal API Pipeline for Exports

Here is the heart of the app, simplified for the article but structurally identical to a production setup:

using System.Security.Claims;
using DataExportApi.Middleware;
using DataExportApi.Models;
using DataExportApi.Services;
using DataExportApi.Mapping;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi.Models;
using Serilog;
using Mapster;
using MapsterMapper;

var builder = WebApplication.CreateBuilder(args);

// Serilog
builder.Host.UseSerilog((ctx, cfg) => cfg.ReadFrom.Configuration(ctx.Configuration));
Enter fullscreen mode Exit fullscreen mode

We then wire authentication, authorization, rate limiting, Swagger and Mapster — and finally map export endpoints.

Instead of building a “kitchen sink” controller, we treat each export as an explicit, versioned endpoint (/v1/export/call-records, /v1/raw-data, etc.). That makes governance and documentation much easier.


4. Authentication & Authorization: Admin-Only Exports

For a data export API, who is calling matters more than almost anything else.

We configure JWT Bearer auth using Azure AD / Entra ID settings from configuration:

// AuthN – Azure AD / Entra ID (wire your real values in appsettings)
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["AzureAd:Authority"];
        options.Audience  = builder.Configuration["AzureAd:Audience"];
        options.TokenValidationParameters.ValidIssuer = builder.Configuration["AzureAd:Issuer"];
        options.TokenValidationParameters.ValidateIssuer = true;
        options.TokenValidationParameters.ValidateAudience = true;
        options.TokenValidationParameters.ValidateLifetime = true;
        options.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(2);
    });
Enter fullscreen mode Exit fullscreen mode

And a simple but strict AdminOnly policy:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy =>
        policy.RequireRole("Admin"));
});
Enter fullscreen mode Exit fullscreen mode

Later, we can attach it selectively:

app.MapGet("/v1/export/metadata/call-records", /* ... */)
   .RequireAuthorization("AdminOnly");
Enter fullscreen mode Exit fullscreen mode

Design tip:

Treat export endpoints like you would treat direct database access. If an attacker can hit them, they should already have passed multiple layers of auth and authorization — not just a “logged‑in user” check.


5. Rate Limiting: Don’t Let Exports Become a DoS Vector

Export endpoints are highly attractive DoS targets: you’re scanning large tables and returning wide JSON payloads.

ASP.NET Core’s RateLimiting middleware makes protection straightforward:

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddFixedWindowLimiter("api", opt =>
    {
        opt.PermitLimit = 300;
        opt.Window = TimeSpan.FromMinutes(1);
    });
});
Enter fullscreen mode Exit fullscreen mode

Later, attach the limiter by name per endpoint:

app.MapGet("/v1/raw-data", /* ... */)
   .RequireRateLimiting("api");
Enter fullscreen mode Exit fullscreen mode

Hard‑won lesson:

Even in internal systems, exports scale badly if a single user can hammer the endpoint unconstrained. Rate limiting is cheaper than tuning SQL under attack.


6. Mapster: Domain Models → Safe Export DTOs

Exporting raw EF entities is how internal IDs and PII leak into spreadsheets.

We use Mapster to:

  • Flatten domain models into DTOs designed for export.
  • Mask or drop fields that must never leave the backend.
  • Generate synthetic IDs that still let BI tools join datasets without exposing primary keys.

Somewhere in DataExportApi.Mapping.MapsterConfig we register mappings like:

public static class MapsterConfig
{
    public static void RegisterMaps()
    {
        TypeAdapterConfig<CallRecord, CallRecordExportDto>
            .NewConfig()
            .Map(dest => dest.SyntheticId,
                 src  => SyntheticId.Create("call", src.InternalId.ToString()))
            .Ignore(dest => dest.InternalId);   // never export raw PK
    }
}
Enter fullscreen mode Exit fullscreen mode

And in Program.cs we hook Mapster into DI:

// Mapster configuration
MapsterConfig.RegisterMaps();
builder.Services.AddSingleton<IMapper, ServiceMapper>();
Enter fullscreen mode Exit fullscreen mode

This gives us a clean IMapper abstraction we can use inside minimal APIs.


7. OpenAPI & the WithOpenApi Trap

Minimal APIs let you customize OpenAPI metadata per route with:

app.MapGet("/v1/raw-data", /* ... */)
   .WithOpenApi();
Enter fullscreen mode Exit fullscreen mode

But many people hit this compiler error:

'RouteHandlerBuilder' does not contain a definition for 'WithOpenApi'

The fix has two parts:

  1. Add the correct package (matching your target framework):
   dotnet add package Microsoft.AspNetCore.OpenApi --version 8.0.13
Enter fullscreen mode Exit fullscreen mode

(For .NET 8, use an 8.x version; for .NET 10, use the matching 10.x preview once available.)

  1. Add the using:
   using Microsoft.AspNetCore.OpenApi;
Enter fullscreen mode Exit fullscreen mode

Without the package, Swashbuckle still works and generates Swagger — but the WithOpenApi() extension does not exist on RouteHandlerBuilder, hence the error.

Inside Program.cs we also configure Swashbuckle to understand our bearer tokens:

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "Data Export API", Version = "v1" });
    c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme
    {
        Type = SecuritySchemeType.Http,
        Scheme = "bearer",
        BearerFormat = "JWT",
        Description = "Azure AD Bearer token"
    });
    c.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "bearerAuth"
                }
            },
            Array.Empty<string>()
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

Now Swagger UI will show a “Authorize” button with JWT support, and each endpoint annotated with .WithOpenApi() will have strong OpenAPI metadata.


8. Putting It All Together: Export & Metadata Endpoints

Let’s look at the three important endpoints this API exposes.

8.1 Health Check

app.MapGet("/v1/health", () => Results.Ok(new { status = "ok" }));
Enter fullscreen mode Exit fullscreen mode

Simple, but invaluable for Kubernetes probes and App Service health checks.

8.2 Generic Raw Data Export

This endpoint showcases paging, filtering and synthetic IDs:

app.MapGet("/v1/raw-data", async (
    HttpContext http,
    [AsParameters] RawQuery q,
    IRawDataService dataSvc) =>
{
    var take = Math.Min(q.Limit is > 0 ? q.Limit.Value : 100, 100);
    var page = await dataSvc.QueryAsync(q.Filter, q.NextPageToken, take, http.RequestAborted);

    var items = page.Items.Select(r =>
    {
        var shape = FieldProjector.ToApiShape(r);
        shape["syntheticId"] = SyntheticId.Create("raw", r.InternalId.ToString());
        return shape;
    });

    return Results.Ok(new
    {
        items,
        page = new
        {
            limit = take,
            nextPageToken = page.NextToken,
            count = page.Items.Count
        }
    });
})
.RequireRateLimiting("api")
// .RequireAuthorization("AdminOnly")   // enable once Entra ID is wired
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status429TooManyRequests)
.WithOpenApi();

public record RawQuery(string? Filter, int? Limit, string? NextPageToken);
Enter fullscreen mode Exit fullscreen mode

Ideas you can steal:

  • Cap Limit server‑side even if the client is greedy.
  • Use a NextPageToken string instead of raw skip/take — makes it easier to optimize the underlying query later (by encoding cursor state).

8.3 Call Records Export (Masked + DTO via Mapster)

app.MapGet("/v1/export/call-records", async (
    ICallRecordService svc,
    IMapper mapper,
    CancellationToken ct) =>
{
    var records = await svc.GetSampleAsync(ct);
    var dto = mapper.Map<List<CallRecordExportDto>>(records);

    return Results.Ok(new
    {
        items = dto,
        count = dto.Count
    });
})
.RequireRateLimiting("api")
// .RequireAuthorization("AdminOnly")
.WithOpenApi();
Enter fullscreen mode Exit fullscreen mode

Here we explicitly do not return domain entities. Only the DTO is allowed across the HTTP boundary.

8.4 Metadata Endpoint: Documenting the Export Surface

An under‑used pattern is to describe exports as data, not just as prose docs.

app.MapGet("/v1/export/metadata/call-records", async (
    ICallRecordService svc,
    IMapper mapper,
    CancellationToken ct) =>
{
    // Build field-level metadata (from attributes)
    var fields = ApiMetadataBuilder.BuildFor<CallRecord>();

    // Provide a sample payload (already mapped to safe DTO)
    var sampleDomain = await svc.GetSampleAsync(ct);
    var sampleExport = mapper.Map<List<CallRecordExportDto>>(sampleDomain);

    var response = new EntityMetadataResponse<CallRecordExportDto>(
        EntityName: "CallRecord",
        Version: "v1",
        Fields: fields,
        Sample: sampleExport);

    return Results.Ok(response);
})
.RequireRateLimiting("api")
.RequireAuthorization("AdminOnly")
.WithOpenApi();
Enter fullscreen mode Exit fullscreen mode

Frontends (or even BI tools) can call this endpoint to:

  • Dynamically build column pickers.
  • Show descriptions and data types per field.
  • Display masked samples instead of fake lorem ipsum.

Once you start thinking of metadata as a first‑class export, your admin portal UX gets much richer almost for free.


9. Production Notes & Hard-Won Lessons

A few pragmatic tips if you want to take this Data Export API beyond the lab:

  • Enforce HTTPS at multiple layers

    • Use middleware (UseHttpsRedirection / custom EnforceHttpsMiddleware) and platform‑level HTTPS (App Service, reverse proxy).
  • Wire real Entra ID / Azure AD auth before going live

    • Uncomment .RequireAuthorization("AdminOnly") once you have real tokens flowing.
    • Log token validation failures with enough context, but never log raw tokens in production.
  • Move heavy exports off the synchronous request path

    • The sample endpoints are synchronous HTTP exports. For massive datasets, consider a “start export” → background job → “download when ready” model backed by blob storage.
  • Version your export contracts

    • Don’t casually change CallRecordExportDto fields. Treat exports like external APIs — add versions (v1, v2) when you need breaking changes.
  • Keep DTOs and domain models clearly separated

    • Mapster helps, but the real win is the discipline of never returning EF entities directly.

Closing Thoughts

Minimal APIs in .NET 8 and 10 make it easy to expose endpoints.

The hard part is exposing endpoints you won’t regret later.

By combining:

  • Strict JWT auth with Entra ID,
  • A focused AdminOnly policy,
  • Built‑in rate limiting,
  • A mapping layer with Mapster,
  • And OpenAPI/Swagger that documents everything,

…you end up with a Data Export API that feels more like a product than a helper script.

Take this sample, inject your real domain services, and you’ll have a secure export surface your operations team can rely on — and auditors can understand.

Happy exporting. 📦📊

Top comments (0)