Partial-response JSON for ASP.NET Core: API clients ask for exactly the fields they need. v8 is a Roslyn source generator for System.Text.Json and Native AOT.
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.