Popcorn is a .Net Middleware for your RESTful API that allows your consumers to request exactly as much or as little as they need, with no effort from you.
This project is maintained by Skyward App Company
This walkthrough builds a minimal ASP.NET Core web API with Popcorn v8 end-to-end: models, endpoints, and include-aware responses. If you already have an app and just need the wire-up steps, see the short Quick Start instead.
A brief note on the two Popcorn versions. Popcorn v1 through v7 (on NuGet as
Skyward.Api.Popcornand.DotNetCore) used runtime reflection to walk response objects and filter fields. That approach is incompatible with .NET’s AOT compilation and IL trimming — two deployment features that are increasingly common in newer .NET stacks.Popcorn v8 (this tutorial) is a rewrite on top of a Roslyn source generator. At build time the generator reads your
[JsonSerializable(typeof(ApiResponse<T>))]declarations, walks the type graph, and emits a straight-lineJsonConverter<T>per reachable type — no reflection at runtime, no metadata the trimmer can strip. The URL grammar, attribute semantics, and response envelope shape are unchanged; only the internals and extension-point API moved.Coming from v7? See the v7 → v8 migration guide — most of the changes are find-and-replace.
dotnet new web -n PopcornDemo
cd PopcornDemo
We’ll use minimal APIs — they compose cleanly with AOT publishing and reduce boilerplate. If
you prefer controllers, the same setup applies; IPopcornAccessor is injected the same way.
dotnet add package Skyward.Api.Popcorn.SourceGen.Shared --version 8.0.0-preview.1
dotnet add package Skyward.Api.Popcorn.SourceGen --version 8.0.0-preview.1
The csproj will look like:
<ItemGroup>
<PackageReference Include="Skyward.Api.Popcorn.SourceGen.Shared" Version="8.0.0-preview.1" />
<PackageReference Include="Skyward.Api.Popcorn.SourceGen" Version="8.0.0-preview.1" PrivateAssets="all" />
</ItemGroup>
SourceGen is marked developmentDependency — it only contributes the Roslyn analyzer, never
a runtime DLL. SourceGen.Shared carries the attributes, envelopes, and middleware.
Create a Models folder and add Employee.cs and Car.cs:
namespace PopcornDemo.Models;
public class Employee
{
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public DateTimeOffset Birthday { get; set; }
public int VacationDays { get; set; }
public List<Car> Vehicles { get; set; } = new();
}
public class Car
{
public string Make { get; set; } = "";
public string Model { get; set; } = "";
public int Year { get; set; }
public Colors Color { get; set; }
}
public enum Colors { Black, Red, Blue, Gray, White, Yellow }
Unlike v7, there is no separate “projection class” step — Popcorn serializes your model directly. You control what’s exposed through attributes on the model itself (next step).
Add ExampleContext.cs in the same folder:
namespace PopcornDemo.Models;
public class ExampleContext
{
public List<Employee> Employees { get; }
public List<Car> Cars { get; }
public ExampleContext()
{
var firebird = new Car { Make = "Pontiac", Model = "Firebird", Year = 1981, Color = Colors.Blue };
var ferrari = new Car { Make = "Ferrari N.V.", Model = "250 GTO", Year = 1962, Color = Colors.Red };
var cayman = new Car { Make = "Porsche", Model = "Cayman", Year = 2005, Color = Colors.Yellow };
var liz = new Employee { FirstName = "Liz", LastName = "Lemon", Birthday = new DateTimeOffset(1981,5,1,0,0,0,TimeSpan.Zero), VacationDays = 0, Vehicles = [firebird] };
var jack = new Employee { FirstName = "Jack", LastName = "Donaghy", Birthday = new DateTimeOffset(1957,7,12,0,0,0,TimeSpan.Zero), VacationDays = 300, Vehicles = [ferrari, cayman] };
Employees = [liz, jack];
Cars = [firebird, ferrari, cayman];
}
}
Popcorn’s generator discovers types through standard System.Text.Json [JsonSerializable]
attributes. Create AppJsonSerializerContext.cs:
using System.Text.Json.Serialization;
using Popcorn.Shared;
using PopcornDemo.Models;
namespace PopcornDemo;
[JsonSerializable(typeof(ApiResponse<List<Employee>>))]
[JsonSerializable(typeof(ApiResponse<List<Car>>))]
internal partial class AppJsonSerializerContext : JsonSerializerContext { }
List top-level response types — the generator walks nested types (like Car inside
Employee.Vehicles) automatically.
Program.csReplace the generated Program.cs with:
using System.Text.Json;
using Popcorn.Shared;
using PopcornDemo;
using PopcornDemo.Models;
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<ExampleContext>();
builder.Services.AddPopcorn();
builder.Services.ConfigureHttpJsonOptions(o =>
{
o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
o.SerializerOptions.AddPopcornOptions(); // installs generator-emitted converters
});
var app = builder.Build();
app.UsePopcornExceptionHandler(); // unhandled exceptions → ApiError envelope
app.MapGet("/employees", (IPopcornAccessor access, ExampleContext db) =>
access.CreateResponse(db.Employees));
app.MapGet("/cars", (IPopcornAccessor access, ExampleContext db) =>
access.CreateResponse(db.Cars));
app.Run();
Three things worth noting:
AddPopcorn() registers the per-request IPopcornAccessor that parses ?include=.AddPopcornOptions() is an extension emitted by the source generator; it installs one
JsonConverter<T> per type reachable from your JsonSerializerContext.UsePopcornExceptionHandler() wraps unhandled exceptions in an ApiResponse<T> envelope
with Success = false and a populated ApiError. See
the migration guide §6
for custom envelope shapes.WebApplication.CreateSlimBuilder(args) is the AOT-friendly host; if you don’t plan to publish
as AOT you can use WebApplication.CreateBuilder(args) instead.
dotnet run
Call the endpoint with no ?include=:
GET /employees
{
"Success": true,
"Data": [
{
"FirstName": "Liz",
"LastName": "Lemon",
"Birthday": "1981-05-01T00:00:00+00:00",
"VacationDays": 0,
"Vehicles": [
{ "Make": "Pontiac", "Model": "Firebird", "Year": 1981, "Color": 2 }
]
},
{ "FirstName": "Jack", "LastName": "Donaghy", ... }
]
}
The default is “everything” because we haven’t applied any [Default] / [Always] attributes
yet — see the Default Includes tutorial for how to change
that.
GET /employees?include=[FirstName,LastName]
{
"Success": true,
"Data": [
{ "FirstName": "Liz", "LastName": "Lemon" },
{ "FirstName": "Jack", "LastName": "Donaghy" }
]
}
Nested includes work recursively:
GET /employees?include=[FirstName,Vehicles[Make]]
{
"Success": true,
"Data": [
{ "FirstName": "Liz", "Vehicles": [{ "Make": "Pontiac" }] },
{ "FirstName": "Jack", "Vehicles": [{ "Make": "Ferrari N.V." }, { "Make": "Porsche" }] }
]
}
This is the point of Popcorn: the client decides exactly which fields to transfer, the server never materializes the rest.
?include=
grammar, including !all, !default, and negation via -Field.[Default], [Always],
[SubPropertyDefault("[Make,Model]")] — control what a bare ?include= request returns.[Never] for fields that must not
leave the server regardless of what the client asks for.Translate<T>(...) lambdas.System.Text.Json and v7.PopcornAotExample — reference project that publishes
with PublishAot=True + PublishTrimmed=True.