Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions dotnet/EcencyApi.Tests/CachePolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using EcencyApi.Infrastructure;
using Xunit;

namespace EcencyApi.Tests;

/// <summary>
/// The cache policies are a public contract: `public` lets shared caches store a
/// response, and the max-age is how long a wrong one would stay wrong. Pin both
/// the opt-in rule and the shape of each policy.
/// </summary>
public class CachePolicyTests
{
public static TheoryData<string> AllPolicies() =>
new() { CachePolicy.ProMembers, CachePolicy.Announcements, CachePolicy.PostTips };

[Theory]
[MemberData(nameof(AllPolicies))]
public void EveryPolicyIsPubliclyCacheableWithAMaxAge(string policy)
{
Assert.StartsWith("public, max-age=", policy);
Assert.DoesNotContain("no-store", policy);
Assert.DoesNotContain("private", policy);
}

[Theory]
[MemberData(nameof(AllPolicies))]
public void APolicyOnlyAppliesToASuccessfulResponse(string policy)
{
Assert.Equal(policy, CachePolicy.ForStatus(200, policy));

// Pipe() turns upstream transport failures into these after the handler
// has already attached the policy. Caching one would keep a healthy
// endpoint broken for the whole max-age.
Assert.Null(CachePolicy.ForStatus(504, policy));
Assert.Null(CachePolicy.ForStatus(500, policy));

// Upstream error passthroughs must not be cached either.
Assert.Null(CachePolicy.ForStatus(404, policy));
Assert.Null(CachePolicy.ForStatus(401, policy));
Assert.Null(CachePolicy.ForStatus(429, policy));
}

[Fact]
public void AnnouncementsOutliveTheOtherPolicies()
{
// Announcements are a compile-time constant, so they can only change on a
// deploy; the proxied endpoints track data that moves independently.
Assert.True(MaxAge(CachePolicy.Announcements) > MaxAge(CachePolicy.ProMembers));
Assert.True(MaxAge(CachePolicy.ProMembers) > MaxAge(CachePolicy.PostTips));
}

private static int MaxAge(string policy)
{
var token = policy.Split(',').Select(p => p.Trim())
.Single(p => p.StartsWith("max-age=", StringComparison.Ordinal));
return int.Parse(token["max-age=".Length..]);
}
}
3 changes: 3 additions & 0 deletions dotnet/EcencyApi/Handlers/Announcements.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ public static class Announcements
public static partial class PrivateApi
{
// GET ^/private-api/announcements$
// The payload is the constant above, so it only changes when this repo is
// deployed; clients can hold on to it between page views.
public static async Task GetAnnouncement(HttpContext ctx)
{
ctx.CacheWhenOk(CachePolicy.Announcements);
await ctx.SendJson(200, Announcements.Json());
}
}
5 changes: 4 additions & 1 deletion dotnet/EcencyApi/Handlers/PrivateApi.Core.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,12 @@ public static async Task RewardedCommunities(HttpContext ctx)
}

// Public list of Pro usernames for the Pro badge roster. No auth: this is a
// public, cached list served straight from the backend.
// public, cached list served straight from the backend. The response is the
// same for every caller, so it carries a Cache-Control and clients can reuse
// it across page views instead of refetching the roster on each one.
public static async Task ProMembers(HttpContext ctx)
{
ctx.CacheWhenOk(CachePolicy.ProMembers);
await Upstream.Pipe(ApiClient.ApiRequest("pro-members", HttpMethod.Get), ctx);
}
}
20 changes: 20 additions & 0 deletions dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,26 @@ public static async Task Tips(HttpContext ctx)
await Upstream.Pipe(ApiClient.ApiRequest($"post-tips/{author}/{permlink}", HttpMethod.Get), ctx);
}

/// <summary>
/// GET twin of <see cref="Tips"/>, same upstream call and same body.
///
/// Tips are a plain read keyed only by author/permlink, but the original
/// endpoint is a POST, and a POST response is uncacheable by definition — so
/// every mount refetched it. This variant is addressable by URL and carries a
/// Cache-Control, which lets a client reuse it. The POST stays for clients
/// that have not moved over.
///
/// Author and permlink arrive as single route segments, so neither can smuggle
/// a slash into the upstream path.
/// </summary>
public static async Task TipsGet(HttpContext ctx)
{
var author = MiscRouteParam(ctx, "author");
var permlink = MiscRouteParam(ctx, "permlink");
ctx.CacheWhenOk(CachePolicy.PostTips);
await Upstream.Pipe(ApiClient.ApiRequest($"post-tips/{author}/{permlink}", HttpMethod.Get), ctx);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

public static async Task GameGet(HttpContext ctx)
{
var body = await ctx.ReadBody();
Expand Down
3 changes: 3 additions & 0 deletions dotnet/EcencyApi/Handlers/Routes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ public static void Map(WebApplication app)
app.MapGet("/private-api/waves/following", PrivateApi.WavesFollowing);
app.MapGet("/private-api/waves/trending/tags", PrivateApi.WavesTrendingTags);
app.MapGet("/private-api/waves/trending/authors", PrivateApi.WavesTrendingAuthors);
// Cacheable twin of the POST /private-api/post-tips below; both hit the
// same upstream. Registered as GET so the response can be reused.
app.MapGet("/private-api/post-tips/{author}/{permlink}", PrivateApi.TipsGet);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record the new GET route as an intentional divergence

When the parity harness compares this build with the legacy Node image, driver.py derives the case /private-api/post-tips/x/x::get from this route. The legacy service has no matching GET route and returns its HTML fallback, while this handler returns the mock upstream JSON; because this case is absent from KNOWN_DIVERGENCES, every documented Node-vs-C# parity diff now reports a mismatch and exits nonzero. Add the deterministic route addition to the divergence list so the harness remains usable.

Useful? React with 👍 / 👎.


// ---- Private Api (POST) ----
app.MapPost("/private-api/comment-history", PrivateApi.CommentHistory);
Expand Down
46 changes: 46 additions & 0 deletions dotnet/EcencyApi/Infrastructure/CachePolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace EcencyApi.Infrastructure;

/// <summary>
/// Cache-Control policies for the handful of private-API reads that are the same
/// for every visitor.
///
/// Most endpoints here are per-user or per-request and deliberately carry no
/// Cache-Control at all. The edge treats a missing Cache-Control as "do not
/// store", so a response only becomes reusable once a handler opts in — which is
/// why these policies are explicit per endpoint rather than a global default.
///
/// Only opt an endpoint in when its body depends on nothing but the URL: no
/// authenticated user, no request body, no per-caller variation. `public` here
/// means shared caches may store it, so getting that wrong would let one
/// visitor's response reach another.
/// </summary>
public static class CachePolicy
{
/// <summary>
/// Pro badge roster: one global list, no auth. It only moves when someone
/// starts or stops being a Pro member, so a short fresh window plus a long
/// revalidate window keeps badges correct without refetching per page view.
/// </summary>
public const string ProMembers = "public, max-age=600, stale-while-revalidate=3600";

/// <summary>
/// Announcements are a compile-time constant in this repo (Announcements.cs),
/// so the body can only change on deploy.
/// </summary>
public const string Announcements = "public, max-age=1800, stale-while-revalidate=86400";

/// <summary>
/// Tips for a single post, keyed entirely by author/permlink and readable
/// without auth. Tips arrive at any time, so the fresh window stays short;
/// the revalidate window is what absorbs repeat reads within one visit.
/// </summary>
public const string PostTips = "public, max-age=60, stale-while-revalidate=600";

/// <summary>
/// A policy applies only to a successful response. Handlers attach it before
/// the upstream call resolves, and <see cref="Upstream.Pipe"/> can still turn
/// a transport failure into 504/500 afterwards — caching either would pin an
/// error in front of a healthy endpoint for the whole max-age.
/// </summary>
public static string? ForStatus(int status, string policy) => status == 200 ? policy : null;
}
22 changes: 22 additions & 0 deletions dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,26 @@ public static async Task SendJson(this HttpContext ctx, int status, JsonNode? no
/// <summary>Raw node for a body field (undefined -> null).</summary>
public static JsonNode? Field(this JsonObject body, string key) =>
body.TryGetPropertyValue(key, out var v) ? v : null;

/// <summary>
/// Attach a <see cref="CachePolicy"/> to this response, applied only if it
/// finishes as a 200.
///
/// The check has to happen at header-flush time, not at call time: handlers
/// call this before awaiting the upstream, and Pipe() can still replace the
/// status with 504/500 afterwards. OnStarting runs once the status is final
/// and before anything is written.
/// </summary>
public static void CacheWhenOk(this HttpContext ctx, string policy)
{
ctx.Response.OnStarting(() =>
{
var value = CachePolicy.ForStatus(ctx.Response.StatusCode, policy);
if (value != null)
{
ctx.Response.Headers.CacheControl = value;
}
return Task.CompletedTask;
});
}
}
Loading