Serving Your Contracts Over HTTP
Pactify.AspNetCore in one sitting: an attribute, an assembly scan, and a /pacts/{provider} endpoint that serves your contract classes as live JSON - plus what a runtime contracts endpoint is really for.
There's a question pact files don't answer. A pact records what a consumer expected at recording time — but when you're sitting in the consumer codebase wondering "what shape does this service's read model have right now, in the build that's actually running?", the pact is a photograph and you want a window.
The second NuGet package in the Pactify family — Pactify.AspNetCore, from the same open-source DevMentors project this series has been reading — takes a swing at that question. It's the smallest installment in this series because it's the smallest code in the library: one attribute, one middleware extension, ~60 lines. But it packs in assembly scanning, a reappearance of part 2's uninitialized-object trick, a deferred-LINQ subtlety, and a serializer inconsistency — a nice closing exercise in reading a whole feature in one sitting.
The developer experience
Mark the classes that participate in contracts, naming the provider(s) each belongs to:
[PactContract("inventory")]
public class StockLevelModel
{
public Guid Sku { get; set; }
public string Warehouse { get; set; }
public int Available { get; set; }
}
[PactContract("inventory", "warehouse-admin")]
public class ReservationModel
{
public Guid ReservationId { get; set; }
public int Quantity { get; set; }
}
Wire the endpoint into the app:
public void ConfigureServices(IServiceCollection services)
=> services.AddPactify(); // just AddRouting() under the hood
public void Configure(IApplicationBuilder app)
=> app.UsePactifyContractsEndpoint();
And the application now answers GET /pacts/inventory with a JSON map of every contract class registered for that provider name:
{
"StockLevelModel": { "Sku": "00000000-0000-0000-0000-000000000000",
"Warehouse": null, "Available": 0 },
"ReservationModel": { "ReservationId": "00000000-0000-0000-0000-000000000000",
"Quantity": 0 }
}
Live shape documents, straight from the compiled types in the running process. No build step, no schema file to forget to regenerate — if the class changed, the endpoint changed.
The whole implementation
Here it is, condensed only slightly:
// src/Pactify.AspNetCore/Extensions.cs
public static IApplicationBuilder UsePactifyContractsEndpoint(this IApplicationBuilder app)
{
var contracts = AppDomain.CurrentDomain
.GetAssemblies()
.SelectMany(a => a.GetTypes())
.Where(t => t.IsDefined(typeof(PactContractAttribute), false))
.Select(t => new { Providers = t.GetCustomAttribute<PactContractAttribute>().Providers, Type = t });
app.UseRouter(builder =>
builder.MapGet("/pacts/{provider}", async (request, response, data) =>
{
var provider = (string) request.HttpContext.GetRouteData().Values["provider"];
var responseBody = contracts
.Where(a => provider is null || a.Providers.Any(p =>
p.Equals(provider, StringComparison.InvariantCultureIgnoreCase)))
.Select(a => a.Type)
.Select(t => new KeyValuePair<string, object>(t.Name,
FormatterServices.GetUninitializedObject(t)))
.ToDictionary(kv => kv.Key, kv => kv.Value);
var json = JsonConvert.SerializeObject(responseBody);
response.StatusCode = 200;
await response.WriteAsync(json);
}));
return app;
}
Four mechanisms in forty lines, each worth a note.
Discovery is an AppDomain sweep. Every loaded assembly, every type, filtered by attribute. No registration list to maintain — annotate the class and it's discovered — with the standard caveats of this technique: it costs a full type scan, and an assembly not yet loaded when the scan runs contributes nothing. Because the scan sits outside MapGet, it executes once at pipeline-build time… almost. contracts is a LINQ query, and LINQ is lazy — what's captured is the query, re-enumerated (re-scanning GetAssemblies()!) on every request. Fine at these scales; also the textbook illustration of why a .ToList() at capture time is never a wasted keystroke.
Case-insensitive provider matching. /pacts/inventory, /pacts/Inventory — both hit. A pleasing contrast with part 6, where the matching engine compares header names case-sensitively; URLs got the leniency headers didn't.
Shapes come from GetUninitializedObject — again. The same trick as WithBody<TBody>(): allocate without running constructors, serialize the zero-values. That's why every GUID above is zeros and every string null. For a shape endpoint this is arguably the honest choice — the values are conspicuously fake, so nobody mistakes the payload for data. Keyed by t.Name — bare class name, no namespace, so two StockLevelModels in different namespaces silently collide (last one enumerated wins the dictionary — actually no: ToDictionary throws on duplicates; either way, don't).
The serializer is the default one. JsonConvert.SerializeObject(responseBody) — no settings. Meanwhile the core package serializes every pact with PactifySerialization.Settings (camelCase, part 3). So the pact file says "available" and the contracts endpoint says "Available" — two representations of the same property, from the same library, in different casings. Anyone building tooling that compares “recorded pact” against “live shape” hits this immediately and needs a case-insensitive comparison — one more entry in the case-sensitivity ledger this series keeps having to write, and a reminder that serializer settings are architecture, not configuration: define them once, reference them everywhere.
What is a contracts endpoint actually for?
The package doesn't document intent — the endpoint isn't consumed anywhere in the library — so what follows is my read as a user, and each use turned out to have real value and a real ceiling.
Discoverability. GET /pacts/inventory from staging beats spelunking another team's repo for the current read-model shape. The type-name keying and zero-values cap it at “browsable documentation” — but browsable documentation that cannot be stale, because it's emitted by the running binary, is worth having. (OpenAPI serves this need more richly; this costs one attribute.)
Which services does this deployment think it serves? The attribute takes multiple provider names, so the endpoint doubles as a self-declared registry: this binary participates in contracts for inventory and warehouse-admin. In a fleet, a script that curls /pacts/{provider} across deployments and diffs the shapes against yesterday's is a poor-man's schema-drift monitor — I've built exactly that, and its false-positive rate was zero because the source is the binary itself.
CI shape-diffing. The most interesting near-miss: a consumer pipeline could fetch the provider's live shapes and compare them against the properties its pact relies on — contract verification without replaying requests. The casing mismatch, name keying and lack of nesting metadata mean you'd write real glue to get there; the endpoint is a foundation for that tool, not the tool.
Notice what the endpoint is not: it plays no part in pact verification. Parts 2–6's loop — record, publish, retrieve, replay — runs entirely without it. This package is a sidecar idea: provider-published shapes complementing consumer-driven contracts, the two directions of the same conversation. In the Pact ecosystem proper, that role grew into “provider contracts” and bi-directional contract testing (a Pactflow feature); Pactify.AspNetCore is a sixty-line sketch of the same instinct, years earlier and in a form you can read over coffee.
One operational note if you try it: this is reflection-derived internals served over HTTP with no auth hook, on by default for any provider name. Behind the firewall it's harmless and useful; on an internet-facing app, gate it in an IsDevelopment() branch or behind your auth middleware. And it targets the 2.x-era IRouter world (app.UseRouter), which — like the verifier's Startup-convention dependency — dates the package against modern minimal hosting; more on that next time.
That's the last of the code. The whole library has now been read: fluent builders, the pact document, publishers and retrievers, the TestServer replay loop, the matching engine, and this endpoint. The final post steps back from the source to the decision that matters — what this little library taught me about when contract testing pays for itself, what you outgrow, and what you'd move to when you do.