Popcorn

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

Contents
Latest Post

Migrating from Popcorn v7 (and earlier) to v8

Table Of Contents

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) and Skyward.Api.Popcorn.SourceGen.Shared (the runtime library). Install both. The legacy Skyward.Api.Popcorn / Skyward.Api.Popcorn.DotNetCore v7 packages continue to ship from master for at least one release after v8 cuts, so you can install side-by-side during migration. First preview: 8.0.0-preview.1.

At a glance

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

1. Packages and using directives

v7

<PackageReference Include="Skyward.Api.Popcorn.DotNetCore" Version="7.*" />
using Skyward.Popcorn;
using Skyward.Popcorn.Expanders;

v8

<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.

2. Startup configuration

v7

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>());

v8

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:

  • Type registration moved from 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.
  • The v7 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.

3. Attribute renames

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.

4. Include-parameter names now use wire names, not C# names

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.

5. Translators (computed fields)

v7

config.Translate<Employee>(e => e.FullName, e => $"{e.First} {e.Last}");

v8 — one pattern: compute where the data lives.

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.

6. Custom response envelope + error handling

v7

config.SetInspector((data, ctx, exception) =>
{
    if (exception != null) return new { success = false, error = exception.Message };
    return new MyEnvelope<object> { Ok = true, Payload = data };
});

v8 — two clean pieces

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.

7. Projections (replacing MapEntityFramework)

v7

config.MapEntityFramework<CarEntity, CarDto, AppDbContext>();
// Or projection lambdas per property.

v8 — dropped. Three replacement patterns, pick the one that fits.

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.

8. External-type handlers (BlindHandler)

v7

The v7 reflection expander handled externally-defined types (e.g. NetTopologySuite.Geometry) by walking them at runtime.

v8 — use a standard 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.

9. Dropped from v8 (these will not return)

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.

10. Runtime polymorphism limits (AOT non-starter)

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:

  1. Expose the concrete type directly (public Car Vehicle instead of public object Vehicle).
  2. Register every expected derived type via [JsonDerivedType] on the base, and a [JsonSerializable] attribute for each concrete type.
  3. If the value genuinely can’t be typed, handle it outside Popcorn — return a pre-serialized JsonElement or emit the endpoint response manually.

11. Generator diagnostics reference

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

12. Checklist

Before flipping the switch, verify:

  • All Skyward.Popcorn* usings replaced with Popcorn / Popcorn.Shared.
  • Every v7 attribute renamed per §3.
  • Every 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().
  • Endpoints return access.CreateResponse(data) (or your [PopcornEnvelope] wrapper), not raw domain objects decorated with ExpandResultAttribute.
  • Clients updated: ?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 JSG003JSG008 diagnostics you didn’t expect.
  • If targeting AOT: dotnet publish -c Release -p:PublishAot=True succeeds and the resulting binary runs the endpoints end-to-end.

13. Rollback

If you hit a blocker:

  • v7 (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.
  • The v7 and v8 packages are designed to install side-by-side. A single project cannot reasonably use both, but a solution can have some projects on v7 and others on v8 during a rolling migration.

See also

  • Performance — why v8 is faster, with benchmarked ratios vs v7 and raw System.Text.Json.
  • Roadmap — Tier-2 feature ship status (translators with DI, blind handlers).
  • migrationAnalysis.md — the full feature-by-feature feasibility ledger used to decide what survived the v8 cut.
  • apiDesign.md — v8 API design philosophy and surface.