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
popcornConfig.Authorize<Car>(...).Translate(...).SetContext(...)) is fundamentally incompatible with source generation — lambdas live at runtime, the generator needs inputs at build time. Not a technical roadblock; a mandatory API rewrite..SetContext(Dictionary<string,object>) to pass ambient data into lambdas, v2 injects DI services into attribute-tagged methods. This is both AOT-safe and idiomatic modern ASP.NET Core.PopcornNetStandard config API. New NuGet package ID (Skyward.Api.Popcorn.SourceGen or similar), parallel shipping until legacy is deprecated.[Always] — emitted regardless of include list, cannot be negated.[Default] — emitted when include is empty or !default; can be negated with -Name.[Never] — never emitted, even if explicitly requested.[SubPropertyDefault("[Make,Model]")] — when this property is included without explicit sub-children, use this include list as its default. Replaces [SubPropertyIncludeByDefault]. Implemented. Pre-parsed once per process into a generator-emitted static readonly field and substituted at the two nested-Pop<T> callsites (complex member, complex-array element). Explicit sub-children override the attribute; [Always] / [Never] on the sub-type still win.[ExpandFrom(typeof(SourceType))] — was planned to emit a ProjectionType.From(SourceType) copy method. The three real use cases have cleaner answers: [Never] on internal source properties (use case 1), a three-line hand-written factory (use case 2), or Mapster.SourceGenerator for complex mapping (use case 3). See docs/MigrationV7toV8.md §7 for the consumer-facing recommendation.[PopcornEnvelope] — marks a type as the application-wide response envelope. One per app.[PopcornPayload] — marks the property that carries the Pop<T> payload. Required on any [PopcornEnvelope] type.[PopcornError] — marks the optional ApiError? property used by the exception middleware.[PopcornSuccess] — marks the optional bool property set to false on error paths.public partial record Employee(string First, string Last)
{
public string FullName => $"{First} {Last}";
}
Dropped 2026-04-23: [Translator] method with DI. The DI-during-serialization pattern fires N+1 queries on collections, moves I/O into the response-writing path, and requires threading IServiceProvider into JsonSerializerOptions which STJ doesn’t natively support. The v8 answer is endpoint-side resolution: compute where the data lives, then serialize. See docs/MigrationV7toV8.md §5.
services.AddHttpContextAccessor();
services.AddPopcorn(o => o.EnvelopeType = typeof(MyEnvelope<>));
services.AddPopcornEnvelopes();
Dropped 2026-04-23: IPopcornBlindHandler<TFrom,TTo>. Standard System.Text.Json JsonConverter<T> registered on JsonSerializerOptions.Converters covers the full external-type case and composes with Popcorn transparently (Popcorn’s generator falls through to JsonSerializer.Serialize for unknown types, STJ picks up the user’s registered converter). See docs/MigrationV7toV8.md §8.
| Parameter | Purpose | Example |
|---|---|---|
include |
Field selection | ?include=[Id,Name,Items[Name]] |
v2 has no other query parameters. Sorting, pagination, and filtering were explicitly dropped from v2 scope (never used in practice with the legacy engine; complexity not justified).
public record ApiResponse<T>
{
public bool Success { get; init; } = true;
public Pop<T> Data { get; init; }
public ApiError? Error { get; init; } // set by UsePopcornExceptionHandler on error paths
}
public record ApiError(string Code, string Message, string? Detail = null);
[PopcornEnvelope]
public record MyEnvelope<T>
{
[PopcornSuccess] public bool Ok { get; init; } = true;
[PopcornPayload] public T? Payload { get; init; }
[PopcornError] public ApiError? Problem { get; init; }
// Free-form user fields — passed through as-is
public List<string> Messages { get; init; } = new();
}
// Register as the app-wide envelope
services.AddPopcorn(options => options.EnvelopeType = typeof(MyEnvelope<>));
Rules enforced by the generator:
[PopcornPayload] is required on any [PopcornEnvelope] type; absence → diagnostic.[PopcornError] property type must be ApiError? (or compatible).Generator sees [PopcornEnvelope] and emits typed CreateSuccess<T>(Pop<T>) / CreateError<T>(ApiError) factories on a generated PopcornEnvelopeFactory class. Middleware uses these factories.
app.UsePopcornExceptionHandler(); // catches unhandled exceptions, writes
// the configured envelope with Success=false / Error populated
Replaces the legacy SetInspector((data, ctx, exception) => wrapper) pattern. Exception wrapping is a middleware concern; the type-level envelope is a source-gen concern. Clean separation.
?include=[...].POPCORN-INCLUDE header as alternative. PopcornAccessor checks header first, falls back to query. No breaking change.| Feature | V1 (reflection) | V2 (source-gen) | How |
|---|---|---|---|
| Include parsing | ✅ | ✅ | Same PropertyReference parser in Popcorn.Shared |
[IncludeByDefault] / [IncludeAlways] |
✅ | ✅ (renamed [Default] / [Always]) |
Existing |
| Blind expansion (own types) | ✅ | ✅ | Automatic — generator walks reachable types |
| Blind expansion (external types) | ✅ runtime reflection | ❌ Dropped from V2 scope | Use a standard JsonConverter<T> on JsonSerializerOptions.Converters (see docs/MigrationV7toV8.md §8) |
| Blind expansion (runtime-unknown polymorphic) | ✅ | ❌ non-starter under AOT | Live with the break |
[InternalOnly] |
✅ | ✅ (as [Never]) |
Existing |
[SubPropertyIncludeByDefault] |
✅ | ✅ (as [SubPropertyDefault], shipped) |
New attribute, existing parser, pre-parsed-once static field |
Optional property ? prefix |
✅ | ✅ by construction | Generator silently skips unknown include names |
| Sorting | ✅ runtime reflection | ❌ Dropped from V2 scope | Never used in practice; complexity not justified |
| Pagination | ✅ | ❌ Dropped from V2 scope | Never used in practice; complexity not justified |
| Filtering | ✅ | ❌ Dropped from V2 scope | Never used in practice; complexity not justified |
| Authorizers | ✅ lambda config | ❌ Dropped from V2 scope | Never used in practice; complexity not justified |
| Translators (pure transforms) | ✅ lambda config | ✅ C# computed properties | Works today; TranslatorTests has 3 passing |
| Translators (DI-needing) | ✅ lambda config | ❌ Dropped from V2 scope | Resolve at endpoint or via standard JsonConverter<T> (see docs/MigrationV7toV8.md §5) |
| Factories | ✅ lambda config | ⏸ moot until deserialization | Write path doesn’t instantiate |
| Contexts (dictionary) | ✅ | ❌ superseded by DI | Drop the dictionary concept entirely |
| Inspectors | ✅ lambda config | ✅ via envelope type + middleware | Split: type for shape, middleware for exceptions |
| Lazy loading | ✅ | ✅ by construction | Generator never touches excluded props |
ExpandFrom / projections |
✅ MapEntityFramework<S,P,Ctx> |
❌ Dropped from V2 scope | Use [Never] on source, a hand-written factory, or Mapster.SourceGenerator (see docs/MigrationV7toV8.md §7) |
| Custom envelope + exception middleware | ✅ lambda config | ✅ via [PopcornEnvelope] markers + UsePopcornExceptionHandler |
Generator emits factories; middleware dispatches |
| Deserialization | ❌ | ⏸ deferred | Out of scope for v2.0 |
[IncludeByDefault] renamed [Default], [IncludeAlways] renamed [Always], [InternalOnly] renamed [Never].SetContext(Dictionary<string,object>) removed — use DI.SetInspector(lambda) removed — use envelope type + middleware.MapEntityFramework<TSource,TProjection,TContext> removed — projections are now direct-serialize-the-source with [Never] on internal properties, a hand-written From(TSource) factory, or Mapster.SourceGenerator for complex mapping. [ExpandFrom] was considered and dropped (see docs/MigrationV7toV8.md §7).?sort=, ?page=, ?filter=, or .Authorize<T>(...) must implement these themselves at the endpoint level.