Bsft.Http
The HTTP primitives more than one framework endpoint has to agree on: a strong entity tag, a
deterministic content digest to build one out of, the reading of If-None-Match, the reading of
If-Match a conditional write is gated on, and the caching policy that belongs on a response a
client is meant to revalidate rather than reuse blindly.
Bsft.Http registers no services and has no dependency beyond ASP.NET Core itself. Everything in
it is a pure function of its arguments.
Why a package rather than two copies
Conditional GET is four decisions — how the tag is built, what counts as a match, what a malformed
validator does, and what Cache-Control goes out beside it — and every one of them is wrong in a
way that is invisible until a client is serving a stale body. Two endpoints spelling them
separately drift, and the drift shows up as a caching bug in production rather than as a failing
test.
Bsft.Storage predates this package and answers the same questions itself, in
StorageEntityTags and DownloadRequestHeaders. The tag grammar here is deliberately the one it
already serves, so a client needs one implementation to revalidate a download, a translation
bundle and a permission map. Converging the storage helpers onto this package is a follow-up, not
part of the change that introduced it: those helpers also carry the range-request rules, where a
weak validator is a correctness problem rather than a wasted round-trip, and that is not a surface
to refactor in passing.
The tag
string?[] segments = ["en", tenantId, "2", "greeting", "Hello", "bye", "Goodbye"];
var tag = segments.ToCanonicalHash().ToContentHashEntityTag(); // "sha256-<64 hex chars>"
ToContentHashEntityTag produces a strong, quoted validator. It sanitises what it is given
to the characters RFC 9110 allows inside a tag, so a digest that somehow arrived with a carriage
return in it cannot become a second response header.
A resource that already carries a concurrency token of its own does not need the content digest route — its token is the thing that changes when it changes:
var tag = product.Version.ToVersionEntityTag(); // "sha256-<64 hex chars>"
Same grammar, so a client parses one shape whatever it is revalidating. The token is digested
rather than published as the tag body, which keeps it inside the server: a rowversion handed out
verbatim tells every caller how often the database has been written to. The Guid overload spells
the token N — 32 hexadecimal digits, no hyphens — because two spellings of one version would be
two tags for one resource; a token that is not a Guid is converted to text by its caller, which
is the only place that knows whether hexadecimal or base64 is the spelling the resource keeps to.
Deriving a version tag needs the version value and nothing else, which is what lets a list projection tag every row it returns: one hash over one short string per item, no second read.
ToCanonicalHash is where the determinism comes from, and it hashes a sequence of segments rather
than a serialized document on purpose. A digest taken over a serializer's output moves when
property order, number formatting or whitespace move, so two nodes behind one load balancer would
hand out two tags for the same content and every client would revalidate for ever. Handing the
hash an explicit sequence puts that order in reviewable code instead.
Two rules the callers follow, both of which the helper cannot enforce for them:
- Length-prefix, which
CanonicalHashdoes per segment, and frame every variable-length list with its own count, which the caller does. Without the per-segment prefix,"ab" + "c"and"a" + "bc"hash the same. Without the count, three lists hashed one after another are one flat sequence, and a permission that moved from one list to another leaves the digest unchanged. - Sort anything whose enumeration order is not a promise. A dictionary's order follows insertion, which follows a hash set. Two nodes resolving the same content would otherwise hash it into two versions.
The consequence of hashing the content rather than the bytes, stated plainly: the tag identifies the logical representation, not the exact octets on the wire. A host that reconfigures its JSON serializer changes the bytes without changing the tag. That is a startup-time change to a process whose caches do not survive it, and the alternative — serializing twice per request, once to hash and once to send — buys nothing a restart does not already provide.
What the tag does not expose
A 304 is decided by comparing the client's tag against the tag of the representation being served to that same client on that same request, never against a stored tag belonging to somebody else. A caller replaying a tag it guessed therefore learns only whether it guessed its own content, which it already has. There is no confirmation oracle for another caller's representation, and that property comes from where the comparison happens rather than from the hash being hard to invert.
Reading the request
if (httpContext.Request.IsUnchangedFrom(tag))
return Results.StatusCode(StatusCodes.Status304NotModified);
Only If-None-Match is honoured. The tag is a digest of the exact content, so it answers "has this
changed" precisely; If-Modified-Since answers the same question to the nearest second out of a
clock the client and the server do not share, and supporting both means deciding which wins when
they disagree.
Comparison is weak, as the specification requires for this header: a client's W/"x" matches a
server's "x", and * matches any representation that exists at all. A header the code cannot
make sense of is ignored and the full body is served. That is the only safe direction to fail
in — a malformed validator answered with a 304 hands a client a body it never received, while one
answered with a 200 costs bandwidth and nothing else.
Gating a write
The write side reads the other validator, If-Match, and reads it under the opposite rules:
if (!product.Version.ToVersionEntityTag().IsMatchedBy(command.IfMatch))
throw new PreconditionFailedException($"Product '{command.ProductId}' has been modified since it was last read.");
If-None-Match (IsUnchangedFrom) |
If-Match (IsMatchedBy) |
|
|---|---|---|
| Comparison | Weak — W/"x" matches "x" |
Strong — a weak validator never matches |
| Unquoted spelling of the tag | Matches | Does not match |
* |
Matches any representation that exists | Matches whatever version the resource has now |
| Header absent or unreadable | Ignored, full body served | No match, the write is refused |
The asymmetry is the point. The worst outcome of a generous If-None-Match is a body served that
need not have been; the worst outcome of a generous If-Match is an overwrite of a change the
writer never saw. So the read side fails towards serving and the write side fails closed.
IsMatchedBy takes the raw header value rather than the request, so the comparison can sit
wherever the resource's current version is known — in this framework's shape the command handler,
which reads the row and decides. A blank or absent header answers false rather than throwing,
because the only way past the check should be a header that matches.
Telling the caller it forgot the header is a different answer — 428 Precondition Required, not
412 — and it stays with the endpoint, which asks the question before anything has been read and
owns the wording of the refusal:
var ifMatch = Request.Headers.IfMatch.ToString();
if (string.IsNullOrWhiteSpace(ifMatch))
throw new PreconditionRequiredException("This operation requires an If-Match header carrying the version being edited.");
Both exceptions live in Bsft.Core and are mapped by BsftExceptionMiddleware; see
crud-conventions.md. They are
deliberately not thrown from Bsft.Http, which keeps it free of any dependency beyond ASP.NET Core
and free of any opinion about what a given resource's refusal should say.
Writing the response
httpContext.Response.UseRevalidation(tag); // ETag + Cache-Control: private, no-cache
Called on the 304 as well as on the 200. A 304 stands in for the response it replaces and has to carry the same validator, or the client cannot tell which of its stored representations was just confirmed.
private, no-cache is the policy, and the naming of the second directive invites the wrong
reading, so it is worth stating what each half buys:
| Directive | What it does | Why it is here |
|---|---|---|
private |
No shared cache — proxy, CDN, corporate middlebox — may store the response. | These representations depend on the authenticated principal and the tenant of the request, and the URL names neither. A shared cache keyed on the URL would serve one caller's answer to the next. |
no-cache |
The client may keep its copy but must revalidate before reusing it. | This is the bargain the entity tag is for: the client keeps the body, and the round-trip that follows is a conditional request the server answers with an empty 304 whenever nothing changed. |
no-store is deliberately not used. It would forbid the client from keeping the body at all,
which turns every revalidation back into a full download and throws the tag away with it. It is the
right choice for a file download served against a bearer token — which is why
Bsft.Storage.Downloads uses it — and the wrong one for a document the client has already rendered
and will ask for again on every page load.
Shared caching is therefore off, permanently and by construction. There is no option to turn it on: a host that wants one of these responses in a CDN wants a different endpoint.
Declaring a route's permissions
RequiredPermissionsMetadata names the permissions a route stands for. It is metadata and not a
check: the check is the authorization policy the host configured, and this record only says what
that policy is expected to mean, so GET /api/auth/permissions or a console can tell a caller what
it is missing before it makes the call.
group.MapGet("/", ListAsync)
.RequireAuthorization(policy)
.WithMetadata(new RequiredPermissionsMetadata([JobsHttpDefaults.ReadPermission]));
Until 2.0 each of fifteen packages declared its own copy of this record in its own namespace, so a reader had to know every one of them. There is now one type, here, because this is the one package every route-mapping package can reference without taking on anything but the ASP.NET Core shared framework it already carries.
Who uses it
| Endpoint | Package | What the tag covers |
|---|---|---|
GET /api/localization/{locale} |
Bsft.Localization |
The locale, the tenant, and every resolved key/value pair. See localization.md. |
GET /api/auth/permissions |
Bsft.Auth |
The principal, the tenant, the route map and both permission lists. See Bsft.Auth. |
GET/PUT/DELETE /api/products/{id} |
Bsft.Sample.Inventory |
The product's concurrency token, served as ETag and required back as If-Match. See crud-conventions.md. |