.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
- Architecture Mental Model
- .csproj: Targeting .NET 10 and Selecting the Right Packages
- Program.cs: Minimal API Pipeline for Exports
- Authentication & Authorization: Admin-Only Exports
- Rate Limiting: Don’t Let Exports Become a DoS Vector
- Mapster: Domain Models → Safe Export DTOs
-
OpenAPI & the
WithOpenApiTrap - Putting It All Together: Export & Metadata Endpoints
- 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:
-
Domain services
-
IRawDataService,ICallRecordService, etc. - Talk to your databases, queues, warehouses, etc.
-
-
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.
-
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>
Key decisions:
-
net10.0— Gives you the latest JIT, runtime perf, and language features. The sample still works onnet8.0if 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 wantWithOpenApi()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 noWithOpenApiextension onRouteHandlerBuilder.
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));
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);
});
And a simple but strict AdminOnly policy:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Admin"));
});
Later, we can attach it selectively:
app.MapGet("/v1/export/metadata/call-records", /* ... */)
.RequireAuthorization("AdminOnly");
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);
});
});
Later, attach the limiter by name per endpoint:
app.MapGet("/v1/raw-data", /* ... */)
.RequireRateLimiting("api");
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
}
}
And in Program.cs we hook Mapster into DI:
// Mapster configuration
MapsterConfig.RegisterMaps();
builder.Services.AddSingleton<IMapper, ServiceMapper>();
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();
But many people hit this compiler error:
'RouteHandlerBuilder' does not contain a definition for 'WithOpenApi'
The fix has two parts:
- Add the correct package (matching your target framework):
dotnet add package Microsoft.AspNetCore.OpenApi --version 8.0.13
(For .NET 8, use an 8.x version; for .NET 10, use the matching 10.x preview once available.)
- Add the using:
using Microsoft.AspNetCore.OpenApi;
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>()
}
});
});
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" }));
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);
Ideas you can steal:
- Cap
Limitserver‑side even if the client is greedy. - Use a
NextPageTokenstring instead of rawskip/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();
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();
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/ customEnforceHttpsMiddleware) and platform‑level HTTPS (App Service, reverse proxy).
- Use middleware (
-
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.
- Uncomment
-
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
CallRecordExportDtofields. Treat exports like external APIs — add versions (v1,v2) when you need breaking changes.
- Don’t casually change
-
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)