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
Popcorn v8 replaces the runtime-reflection expander (Skyward.Api.Popcorn /
Skyward.Api.Popcorn.DotNetCore, shipped as v7 and earlier on NuGet) with a Roslyn source
generator. The surface area is incompatible by design: every v7 extension point that depended on
runtime lambdas or reflection-scanned types has been redesigned to work at build time, so Popcorn
can run under Native AOT (PublishAot=True) and IL trimming (PublishTrimmed=True).
This guide walks the breaks you will hit, what to change, and what has been dropped outright. If a feature you rely on is in the “Dropped from v8” section, you will need to handle it yourself at the endpoint level — v8 will not ship a replacement.
Packages: v8 ships as two new package IDs —
Skyward.Api.Popcorn.SourceGen(the Roslyn analyzer) andSkyward.Api.Popcorn.SourceGen.Shared(the runtime library). Install both. The legacySkyward.Api.Popcorn/Skyward.Api.Popcorn.DotNetCorev7 packages continue to ship frommasterfor at least one release after v8 cuts, so you can install side-by-side during migration. First preview:8.0.0-preview.1.
| Concept | v7 (reflection) | v8 (source generator) |
|---|---|---|
| Expansion engine | Runtime reflection via Skyward.Popcorn.Expander |
Roslyn source generator emits JsonConverter<T> per type at build time |
| Serializer | Newtonsoft or JsonSerializer via a projection-to-Dictionary<string, object?> |
System.Text.Json with generator-emitted converters; writes directly to Utf8JsonWriter |
| Type discovery | config.Map<Car>() lambda at startup |
[JsonSerializable(typeof(ApiResponse<Car>))] on a JsonSerializerContext subclass |
| Default inclusion | [IncludeByDefault] |
[Default] |
| Always-emit | [IncludeAlways] |
[Always] |
| Never-emit | [InternalOnly] |
[Never] |
| Sub-default includes | [SubPropertyIncludeByDefault] |
[SubPropertyDefault("[Make,Model]")] |
| Computed field | .Translate<Car>(c => c.First + " " + c.Last) lambda |
C# computed property. DI-needing translators resolved at the endpoint layer — see §5 |
| Projection class | MapEntityFramework<TSource, TProjection, TContext> |
Dropped. Decorate source with [Never], hand-write a factory, or use Mapster.SourceGenerator (see §7) |
| External-type conversion | BlindHandler via runtime reflection |
Standard JsonConverter<T> registered on JsonSerializerOptions.Converters — see §8 |
| Ambient data | .SetContext(Dictionary<string, object>) |
Standard ASP.NET Core DI — translator methods receive services as parameters |
| Exception wrapping | .SetInspector((data, ctx, exc) => wrapper) |
UsePopcornExceptionHandler() middleware + [PopcornEnvelope] marker attributes |
| Authorization | .Authorize<T>((src, ctx, val) => …) |
Dropped. Use ASP.NET Core authorization middleware and endpoint-level checks |
| Sorting / Pagination / Filtering | ?sort=…, ?skip/take=…, ?filter=… |
Dropped. Implement at the endpoint level |
| AOT / Trim | Not supported (reflection-heavy) | First-class: PublishAot=True + PublishTrimmed=True validated |
using directives<PackageReference Include="Skyward.Api.Popcorn.DotNetCore" Version="7.*" />
using Skyward.Popcorn;
using Skyward.Popcorn.Expanders;
<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" />
The SourceGen package is marked developmentDependency — it only contributes the Roslyn
analyzer, never any runtime DLLs. The SourceGen.Shared package is the normal runtime
library that carries attributes, envelopes, and middleware.
using Popcorn; // attributes live here
using Popcorn.Shared; // ApiResponse<T>, Pop<T>, ApiError, IPopcornAccessor, middleware extensions
The v7 Skyward.Popcorn* namespaces are gone. There is no compatibility shim — update your
usings.
services.UsePopcorn((config) =>
{
config
.UseDefaultConfiguration()
.Map<Car>()
.Map<Employee>()
.Translate<Employee>(e => e.FullName, e => $"{e.First} {e.Last}")
.Authorize<Car>((source, ctx, value) => (bool)ctx["IsAdmin"] || value.OwnerId == (int)ctx["UserId"])
.SetContext(new Dictionary<string, object> { ["UserId"] = 42, ["IsAdmin"] = false })
.SetInspector((data, ctx, exception) =>
{
if (exception != null)
return new { success = false, error = exception.Message };
return new { success = true, data };
});
});
services.AddControllers(c => c.Filters.Add<ExpandServiceFilter>());
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddHttpContextAccessor();
builder.Services.AddPopcorn(o =>
{
// Optional: use a custom envelope shape. Omit for the default ApiResponse<T>.
o.EnvelopeType = typeof(MyEnvelope<>);
o.DefaultNamingPolicy = JsonNamingPolicy.CamelCase;
});
builder.Services.AddPopcornEnvelopes(); // only needed when EnvelopeType is a custom [PopcornEnvelope]
builder.Services.ConfigureHttpJsonOptions(o =>
{
o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
o.SerializerOptions.AddPopcornOptions(); // installs generator-emitted converters
});
var app = builder.Build();
app.UsePopcornExceptionHandler(); // catches exceptions → envelope with ApiError
// Endpoints receive IPopcornAccessor to parse the ?include= query and wrap responses.
app.MapGet("/cars", (IPopcornAccessor access) => access.CreateResponse(GetCars()));
app.Run();
[JsonSerializable(typeof(ApiResponse<List<Car>>))]
[JsonSerializable(typeof(ApiResponse<Employee>))]
internal partial class AppJsonSerializerContext : JsonSerializerContext { }
The shape of the change:
config.Map<Car>() calls to [JsonSerializable(typeof(ApiResponse<Car>))]
attributes on a JsonSerializerContext subclass. The generator walks the nested type graph from
those registrations, so you generally only list top-level response types.ExpandServiceFilter and ExpandResultAttribute are gone. Endpoints receive
IPopcornAccessor via DI and call access.CreateResponse(data).SetContext(Dictionary<string, object>) is gone. Use standard ASP.NET Core DI — register
services with AddScoped / AddSingleton and inject them where needed.Every Popcorn attribute changed names. Do a project-wide find-and-replace:
| v7 | v8 |
|---|---|
[IncludeByDefault] |
[Default] |
[IncludeAlways] |
[Always] |
[InternalOnly] |
[Never] |
[SubPropertyIncludeByDefault("...")] |
[SubPropertyDefault("...")] |
Semantics are otherwise unchanged: [Always] emits regardless of include list, [Default] emits
when the include list is empty or contains !default, [Never] never emits.
In v7, ?include= matched C# property identifiers. In v8 it matches the wire name (the value
passed to [JsonPropertyName("…")], or the C# name normalized by your JsonNamingPolicy).
This is a hard break for any client code that relied on the C# identifier. A response body
{"display_name": "..."} must be requested as ?include=[display_name]; ?include=[DisplayName]
is a no-op silently and will not emit the field.
This is intentional: the ?include= list is part of the public API contract that clients see.
They have no visibility into C# identifier casing.
config.Translate<Employee>(e => e.FullName, e => $"{e.First} {e.Last}");
Pure transforms — regular C# computed property. Zero framework:
public partial record Employee(string First, string Last)
{
public string FullName => $"{First} {Last}";
}
DI-needing computation — resolve at the endpoint. v8 does not ship a [Translator]
attribute. The DI-during-serialization pattern (translator method takes source + injected
service, serializer invokes it per property) has three problems: it fires N+1 queries when
serializing a collection, it moves I/O into the response-writing path, and it ties the
converter to an ambient IServiceProvider that JsonSerializerOptions doesn’t natively
carry. The clean pattern is:
app.MapGet("/cars", (IPopcornAccessor access, IEmployeeLookup lookup, ICarRepo cars) =>
{
var owners = lookup.FindMany(cars.OwnerIds); // batchable
var view = cars.GetAll().Select(c => new Car
{
Id = c.Id, Make = c.Make,
Owner = owners.GetValueOrDefault(c.OwnerId),
});
return access.CreateResponse(view);
});
Batch-friendly, clear I/O boundaries, trivially testable. Equivalent to v7’s
Translate<Employee>(…) for every case where the lambda closed over a service (which in v7
was awkward to do anyway — the lambda closed over startup-time state, not request-scoped
services).
For the rare case where serialization-time resolution is genuinely desired (e.g. per-request
locale-aware formatting), write a standard JsonConverter<T> that pulls from
IHttpContextAccessor.HttpContext.RequestServices. Popcorn composes with standard STJ
converters.
config.SetInspector((data, ctx, exception) =>
{
if (exception != null) return new { success = false, error = exception.Message };
return new MyEnvelope<object> { Ok = true, Payload = data };
});
Declare the envelope with marker attributes. The generator emits a typed, reflection-free error writer for it:
[PopcornEnvelope]
public record MyEnvelope<T>
{
[PopcornSuccess] public bool Ok { get; init; } = true;
[PopcornPayload] public Pop<T>? Payload { get; init; }
[PopcornError] public ApiError? Problem { get; init; }
// Free-form fields pass through as-is.
public List<string> Messages { get; init; } = new();
}
Register and wire the middleware:
builder.Services.AddPopcorn(o => o.EnvelopeType = typeof(MyEnvelope<>));
builder.Services.AddPopcornEnvelopes();
app.UsePopcornExceptionHandler();
Return it from endpoints:
app.MapGet("/cars", (IPopcornAccessor access) =>
new MyEnvelope<List<Car>>
{
Payload = new Pop<List<Car>> { Data = GetCars(), PropertyReferences = access.PropertyReferences }
});
The middleware wraps unhandled exceptions in your envelope shape with Problem populated
(ApiError(Code, Message, Detail?)) and status 500.
MapEntityFramework)config.MapEntityFramework<CarEntity, CarDto, AppDbContext>();
// Or projection lambdas per property.
The v7 MapEntityFramework intercepted serialization: when the expander saw CarEntity it
automatically produced a CarDto-shaped response. v8 does not replicate that. The three
scenarios it covered each have a cleaner v8 answer.
(A) Hiding internal fields on the API surface — the most common case. Decorate the source type directly:
public class CarEntity
{
public int Id { get; set; }
public string Make { get; set; }
public string Model { get; set; }
[Never] public string InternalNotes { get; set; } // never leaves the server
}
Zero projection class. One source of truth. Works if your entity can carry API-layer attributes.
(B) Hard boundary between domain and API — write a three-line factory.
public record CarDto(int Id, string Make, string Model)
{
public static CarDto From(CarEntity src) => new(src.Id, src.Make, src.Model);
}
// In the endpoint:
app.MapGet("/cars", (IPopcornAccessor access) =>
access.CreateResponse(GetCars().Select(CarDto.From).ToList()));
Explicit, trimmer-safe, obvious when you add a field and forget to wire it.
(C) Complex mapping — nested, flattening, custom resolvers — use Mapster.SourceGenerator. It is AOT-compatible, actively maintained, and solves the same problem more thoroughly than anything bundled with Popcorn would. Popcorn deliberately does not ship a mapper.
[AdaptTo(typeof(CarDto))] public partial class CarEntity { /* ... */ }
Mapster and Popcorn compose — Mapster produces the DTO, Popcorn’s generator emits the JSON converter for it.
BlindHandler)The v7 reflection expander handled externally-defined types (e.g. NetTopologySuite.Geometry)
by walking them at runtime.
JsonConverter<T>.v8 does not ship IPopcornBlindHandler<TFrom, TTo>. The standard System.Text.Json converter
surface already covers this cleanly and works under AOT:
public class GeometryConverter : JsonConverter<Geometry>
{
public override void Write(Utf8JsonWriter writer, Geometry value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToText()); // WKT
public override Geometry Read(…) => throw new NotImplementedException();
}
// Register globally — Popcorn composes with it transparently.
options.Converters.Add(new GeometryConverter());
options.AddPopcornOptions();
Popcorn’s generator walks your types and falls through to JsonSerializer.Serialize for any
type it doesn’t recognize. STJ then picks up your registered converter. Collections and
dictionaries of external types work the same way — one converter handles all reachable uses.
Edge case: include filtering through an external type. If you want
?include=[Location[Type,Coordinates]] to filter fields inside the external type’s
projection, wrap it in a Popcorn-registered class:
public class GeometryEnvelope
{
public string Type { get; init; }
public double[] Coordinates { get; init; }
public double[]? Bbox { get; init; }
public static GeometryEnvelope From(Geometry g) => /* … */;
}
// Use GeometryEnvelope on the API-facing model instead of Geometry directly.
Then include filtering reaches normally through your wrapper class.
| Feature | What to do instead |
|---|---|
.Authorize<T>(…) |
Use ASP.NET Core authorization — [Authorize] attributes, policy handlers, endpoint-level checks |
?sort=… (built-in sorting) |
Accept a sort parameter in your endpoint and apply it to your query (IQueryable.OrderBy, etc.) |
?skip=…&take=… (built-in pagination) |
Accept paging parameters and apply at the query layer |
?filter=… (built-in filtering) |
Parse the filter grammar you want at the endpoint (OData, custom, etc.) and apply in your query |
SetContext(Dictionary<string, object>) |
Standard DI — register services and inject them |
SetInspector for success-shape rewriting |
Use a [PopcornEnvelope] type as the shape; the middleware covers the error case |
Legacy PopcornFactory.CreatePopcorn() manual expansion |
Call IPopcornAccessor.CreateResponse(data) and let the generator-emitted converter do the work |
ExpandServiceFilter / ExpandResultAttribute |
Return access.CreateResponse(data) (or your [PopcornEnvelope] type) directly from the endpoint |
Rationale: the dropped features were either very rarely used in practice, or duplicated what
modern ASP.NET Core already provides cleanly. See
migrationAnalysis.md for the full scope decision.
v7 happily serialized properties typed as object, abstract classes, or interfaces — reflection
walked whatever runtime type happened to be in the slot. Under AOT and trimming, the trimmer
removes that metadata, so the generator can’t emit anything useful for those shapes.
v8 emits diagnostic JSG008 when it sees a member whose static type is object, an abstract
class, or an interface:
JSG008 Member 'Bag.Tag' is typed as 'object', whose concrete runtime type cannot be resolved
at build time. Popcorn's source generator cannot emit a converter for this shape under
Native AOT or IL trimming.
Fix options, in order of preference:
public Car Vehicle instead of public object Vehicle).[JsonDerivedType] on the base, and a
[JsonSerializable] attribute for each concrete type.JsonElement or emit the endpoint response manually.Warnings you might see from the v8 generator, with the shape of the fix:
| ID | Trigger | Fix |
|---|---|---|
JSG001 |
Generator threw during emission | Report a bug — include the triggering type |
JSG002 |
Informational log from generator | None — informational only |
JSG003 |
[PopcornEnvelope] type has no [PopcornPayload] property |
Add a Pop<T> property marked with [PopcornPayload] |
JSG004 |
Multiple properties share the same envelope marker | Remove the duplicate marker |
JSG005 |
[PopcornPayload] property is not typed Pop<T> |
Change the property type to Pop<T> |
JSG006 |
[PopcornError] property is not ApiError or ApiError? |
Change the type to ApiError? |
JSG007 |
Envelope declared inside a generic outer type | Move the envelope to the top level or a non-generic container |
JSG008 |
Property typed as object / abstract class / interface |
See §10 above |
Before flipping the switch, verify:
Skyward.Popcorn* usings replaced with Popcorn / Popcorn.Shared.config.Map<T>() call replaced with a [JsonSerializable(typeof(ApiResponse<T>))]
attribute on a JsonSerializerContext subclass.config.Translate(...) lambdas converted to computed properties (pure transforms) or
endpoint-side resolution (DI-needing translations — see §5).config.Authorize(...) removed; authorization moved to endpoint / policy layer.config.SetContext(dict) replaced with DI registrations.config.SetInspector(...) split: shape moves to a [PopcornEnvelope] type; error-wrapping
moves to UsePopcornExceptionHandler().access.CreateResponse(data) (or your [PopcornEnvelope] wrapper), not
raw domain objects decorated with ExpandResultAttribute.?include= uses wire names (display_name), not C# names (DisplayName).?sort= / ?skip= / ?take= / ?filter= handled at the endpoint, not via query-param
framework features.dotnet build emits no JSG003–JSG008 diagnostics you didn’t expect.dotnet publish -c Release -p:PublishAot=True succeeds and the resulting
binary runs the endpoints end-to-end.If you hit a blocker:
Skyward.Api.Popcorn / Skyward.Api.Popcorn.DotNetCore) will remain on NuGet for at least
one release after v8 ships. Revert the package references and the namespace changes.System.Text.Json.