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
Outstanding work on the spike/source-generator branch before it is ready to merge to master and ship as v2. For historical bug/fix context see memory-bank/progress.md and git log.
Last updated: 2026-04-23.
Scope update (2026-04-23). All three Tier-2 features —
[ExpandFrom],[Translator]with DI,IPopcornBlindHandler<TFrom,TTo>— cleared from scope after use-case analysis showed each had a cleaner answer using patterns already native to ASP.NET Core + System.Text.Json. Each drop is documented with a recommended replacement in docs/MigrationV7toV8.md (§5, §7, §8). v2.0 is now feature-complete; the two remaining merge-gate items are both infrastructure (AOT CI + NuGet packaging).
UsePopcornExceptionHandler + [SubPropertyDefault]: shipped.Popcorn.FunctionalTests (2 remaining skips are the polymorphism dispatch feature — see Tier 2 section below). 19 passing in Popcorn.SourceGenerator.Tests. Zero CS86xx warnings in generated code.PopcornAotExample builds with PublishAot=True and exercises a custom [PopcornEnvelope] shape.PopcornNetStandard*): still in the tree, unchanged. Planned removal after v2 ships side-by-side for a release or two.All three planned Tier-2 features were considered and dropped after use-case analysis. The consistent finding: what v7 shipped as dedicated framework surface is, in v8, better served by patterns already native to ASP.NET Core + System.Text.Json. Each drop is documented in the migration guide with the recommended replacement:
[ExpandFrom] — use [Never] on internal source properties, a 3-line hand-written factory, or Mapster.SourceGenerator for complex mapping. See MigrationV7toV8.md §7.[Translator] with DI — resolve at the endpoint (batchable, clear I/O boundaries, testable); computed properties still work for pure transforms. Serializing with injected services is an antipattern (N+1 queries, hidden I/O, scope threading complexity). See MigrationV7toV8.md §5.IPopcornBlindHandler<TFrom,TTo> — standard JsonConverter<T> registered on JsonSerializerOptions.Converters covers the full use case and composes with Popcorn transparently. See MigrationV7toV8.md §8.If a real consumer presents a concrete case that none of the replacement patterns cover, the specs live on in the git history and can be revived — but spec-driven shipping of features nobody has asked for is the shape of complexity we are deliberately shedding.
PolymorphismTests.cs.
PolymorphicCollection_EmitsDiscriminator_WhenConfigured: abstract/interface base + registered derived types via [JsonDerivedType]; generator needs to emit per-item type-dispatch.ExpanderGenerator.cs:PolymorphicUnknownDescriptor). Generator now emits a warning when a member is typed object, abstract class, or interface. 5 tests in EnvelopeDiagnosticsTests.cs cover the positive and negative cases. Registered-derived support (the [JsonDerivedType] dispatch half) remains unimplemented.[Factory]-tagged static method for instantiating types during read.Dictionary<string, object> contexts — dropped (superseded by DI).benchmarks/results/v2-baseline/. Covers Stj (reflection) vs Stj (source-gen) vs Popcorn (source-gen) vs legacy PopcornNetStandard (reflection) across SimpleModel, SimpleModelList[100], ComplexNestedModel, ComplexNestedModelList[25]. Three incremental generator optimizations landed after initial baseline capture — walk-through under opt-iterations/.All, ~5.8× for Default on ComplexModelList). Popcorn-default on ComplexModelList is ~10× faster / ~5× less alloc than STJ reflection. Popcorn-all on ComplexModelList is 0.87× time / 0.93× alloc — Popcorn is faster than STJ when emitting everything on nested data; legacy-all is 3.6× slower than STJ on the same shape..github/workflows/benchmarks.yml runs on PR + push to master / spike/**. dotnet run -- ci in SerializationPerformance runs 5 filtered benchmarks (SimpleJob — 3 warmup + 15 iterations, ~2 min wall time), then .github/scripts/compare-benchmark-ratios.py computes three load-bearing ratios (Popcorn / STJ-source-gen on the same shape) and compares against benchmarks/results/ci-baseline.json. Fails if any ratio regressed by more than thresholdPercent (default 25%). Improvements > 25% print an informational note that reminds you to update the baseline.SimpleModelList_PopcornAll_vs_Stj (worst-case 1.49), ComplexModelList_PopcornAll_vs_Stj (headline 0.87 — Popcorn is faster than STJ), ComplexModelList_PopcornDefault_vs_Stj (selectivity 0.11).ci locally and commit the new baseline in the same PR. When an intentional regression (new feature that trades perf for ergonomics) ships, bump the baseline upward.$GITHUB_STEP_SUMMARY; raw BDN artifacts uploaded for 30 days..github/workflows/tests.yml runs on PR + push to master / spike/**. Installs .NET 8.0 SDK, caches NuGet packages keyed on csproj hashes, runs dotnet test on both Popcorn.FunctionalTests (182 passing / 2 skipped) and Popcorn.SourceGenerator.Tests (19 passing). trx logs uploaded as an artifact on failure. Concurrency-group cancels superseded runs.aot-ci.yml: the AOT workflow needs the AOT toolchain (clang/zlib) + Docker; the test workflow should run faster and with fewer dependencies. Parallel jobs keep PR feedback tight.spike/source-generator without catching. Closed..github/workflows/aot-ci.yml runs on PR + push to master / spike/**. Uses docker/build-push-action@v5 with type=gha cache to build dotnet/PopcornAotExample/Dockerfile (context: dotnet/). Starts the container on port 8080, waits up to 60s for readiness, verifies all four endpoints end-to-end:
/todos — Success:true, Id:1 + Id:2 present, IsComplete ([Never]) absent./null — Data:null in the envelope./sub — Id:1 + nested ToDo object./boom — status 500, Ok:false, Problem populated with the exception message (exercises the exception middleware + generator-emitted custom-envelope error writer).docker logs. Always: stops the container. Concurrency-group cancels superseded runs on the same ref.Skyward.Api.Popcorn.SourceGen.Shared (runtime attributes, envelopes, middleware — from Popcorn.Shared.csproj) and Skyward.Api.Popcorn.SourceGen (analyzer-only, from Popcorn.SourceGenerator.csproj). Side-by-side-installable with legacy Skyward.Api.Popcorn v7 because the IDs diverge.PackageId, Version=8.0.0-preview.1, Authors, Description, PackageTags, PackageProjectUrl, RepositoryUrl, PackageLicenseFile=LICENSE, PackageReadmeFile=README.md, Copyright. Both reference Microsoft.SourceLink.GitHub with PublishRepositoryUrl=true and EmbedUntrackedSources=true. SourceGen is marked DevelopmentDependency=true + SuppressDependenciesWhenPacking=true so it flows analyzer-only and declares no runtime dependencies. SourceGen.Shared has IncludeSymbols=true + SymbolPackageFormat=snupkg.SourceGen embeds Popcorn.Shared.dll into analyzers/dotnet/cs/ (required for Roslyn to resolve attribute symbols during generation). The separate SourceGen.Shared package provides the runtime-visible copy under lib/netstandard2.0/. Consumers install both; they serve different layers..github/workflows/main.yml extended to pack+push both v8 packages alongside the legacy v7 pack steps on tag releases. fetch-depth: 0 added so SourceLink can resolve commit hashes.dotnet pack produces Skyward.Api.Popcorn.SourceGen.Shared.8.0.0-preview.1.nupkg (16 KB, lib/netstandard2.0/Popcorn.Shared.dll + deps) and Skyward.Api.Popcorn.SourceGen.8.0.0-preview.1.nupkg (38 KB, analyzers/dotnet/cs/ containing both dlls, no lib/, no transitive deps). Snupkg generated for Shared.net9.0 classlib with <PackageReference> to both packages from a local feed: packages restored, analyzer ran, generated SmokeConsumerCarJsonConverter.g.cs + SystemCollectionsGenericListSmokeConsumerCarJsonConverter.g.cs + RegisterConverters.g.cs, STJ source generator picked up the emitted Pop<Car> / Pop<List<Car>> types (visible in SmokeJsonContext.PopCar.g.cs / SmokeJsonContext.PopListCar.g.cs), build clean (0 errors, only informational JSG002 logs).docs/Releases.md with the preview entry.8.0.0-preview.1, push tag, CI pushes to NuGet. (Operational — user decision to ship.)git tag + push is done.SetContext(dict), custom envelope + middleware for SetInspector(lambda), include-parameter wire-name contract, JSG008 documentation, rollback plan.<PackageReleaseNotes> banner pointing at MigrationV7toV8.md.”Releases.md (currently empty of v8 entries) with an 8.0.0-preview.1 entry when it cuts.dotnet/Examples/PopcornNet5Example/ still references the v7 reflection engine (services.UsePopcorn((config) => config.UseDefaultConfiguration()), ExpandServiceFilter) and targets net5.0. Either port to v8 (minimal API + IPopcornAccessor + [JsonSerializable] context) or delete and rely solely on dotnet/PopcornAotExample/ as the canonical example.Confirmed against ExpanderGenerator.cs on 2026-04-23.
ExpanderGenerator.cs:908 emits #pragma warning disable CS8619, CS8600, CS8601, CS8625 at file scope. CS8619 / CS8625 are load-bearing (NRT-cast through generated code). CS8600 / CS8601 are pulled in defensively; could theoretically mask a real null bug introduced by a future generator change. Narrow to per-statement where feasible.class SettingsDict : Dictionary<string, string> {} has TypeArguments.Length == 0; ExpanderGenerator.cs:847 accesses namedDictionaryTypeNonNullable.TypeArguments[1] unguarded → IndexOutOfRangeException. No test hits it today. Fix: walk the IDictionary<K, V> interface chain for TypeArguments rather than reading the target type’s own list.IsBlindSerializableType uses stringly-typed hashset lookups. Matches the pre-existing convention (NumberTypes, StringTypes, BoolTypes, IgnoreTypes all compared via ToDisplayString().Replace("?", "")). Fragile to Roslyn display-format changes but consistent. Future cleanup: replace all such lookups with SpecialType / ITypeSymbol identity comparisons.IsNamedTypeCycleSafe (ExpanderGenerator.cs:133-164) treats any type NOT in allTypeNames as cycle-safe. Correct today — unregistered user types fall through to JsonSerializer.Serialize which doesn’t touch Popcorn’s HashSet. Revisit if a future change starts recursing through such types (e.g. IPopcornBlindHandler landing).Three generator-level optimizations considered but not taken in the 2026-04 opt pass. Listed in rough order of expected payoff.
JsonEncodedText. STJ’s own source-gen path does this; saves per-property UTF-16→UTF-8 encoding cost. Complicated by runtime PropertyNamingPolicy (the encoded form depends on options → forces per-options caching). Biggest remaining lever; would likely close most of the remaining SimpleModelList_PopcornAll 1.40× gap vs raw STJ.useAll && !hasNegations. Every property unconditionally emits under !all with no negations, so the scan is pure overhead. A one-time check at the top of the body could bypass the per-property loop entirely. Moderate payoff on Popcorn_All scenarios.POPCORN-INCLUDE)GET /foo?include=[very,long,list,...] can blow past proxy limits. An alternative header carries the same grammar, parsed by PopcornAccessor with header-first / query-fallback priority.PopcornAccessor.PropertyReferences getter checks HttpContext.Request.Headers["POPCORN-INCLUDE"] first, falls back to query ?include=.memory-bank/apiDesign.md, not started. Decision: implement in this spike, or defer to v2.1?A defensible order that minimizes dependency chains and maximizes incremental merge-readiness:
Adjust based on what any real consumer blocks on first.
PopcornNetStandard) committed under benchmarks/results/v2-baseline/.Pop{X}Inner regression on nested-collection registrations..github/workflows/aot-ci.yml.Popcorn.SourceGenerator + Popcorn.Shared — two-package design Skyward.Api.Popcorn.SourceGen + Skyward.Api.Popcorn.SourceGen.Shared, 8.0.0-preview.1, verified locally. Operational tag+push remains.