-
Notifications
You must be signed in to change notification settings - Fork 2
feat(private-api): make the three global reads cacheable #61
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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..]); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| using EcencyApi.Handlers; | ||
| using Xunit; | ||
|
|
||
| namespace EcencyApi.Tests; | ||
|
|
||
| /// <summary> | ||
| /// The tips handlers build an upstream path by interpolating caller-supplied | ||
| /// author and permlink values. Route values arrive percent-decoded and body | ||
| /// values are arbitrary strings, so anything structural left unescaped is | ||
| /// re-parsed when the string becomes a Uri — and the upstream call carries this | ||
| /// service's credentials, so a redirected path is a real problem, not a cosmetic | ||
| /// one. | ||
| /// </summary> | ||
| public class PostTipsPathTests | ||
| { | ||
| [Fact] | ||
| public void RealAuthorsAndPermlinksAreUnchanged() | ||
| { | ||
| // Hive names and permlinks are unreserved characters; escaping must be a | ||
| // no-op for them or this would change every live request. | ||
| Assert.Equal( | ||
| "post-tips/good-karma/my-post-title-2026", | ||
| PrivateApi.PostTipsPath("good-karma", "my-post-title-2026")); | ||
| Assert.Equal( | ||
| "post-tips/user.name/a_b-c.d~e", | ||
| PrivateApi.PostTipsPath("user.name", "a_b-c.d~e")); | ||
| } | ||
|
|
||
| [Theory] | ||
| // A slash would add path segments and address a different resource. | ||
| [InlineData("a/b", "p")] | ||
| [InlineData("a", "p/q")] | ||
| // A question mark would truncate the path and turn the rest into a query. | ||
| [InlineData("a?x=1", "p")] | ||
| [InlineData("a", "p?x=1")] | ||
| // A hash would truncate the path at a fragment. | ||
| [InlineData("a#f", "p")] | ||
| // Dots inside a longer segment are ordinary characters, not structure. | ||
| [InlineData("x..y", "p")] | ||
| public void StructuralCharactersCannotEscapeTheirSegment(string author, string permlink) | ||
| { | ||
| var path = PrivateApi.PostTipsPath(author, permlink); | ||
| Assert.NotNull(path); | ||
|
|
||
| // Exactly three segments: the prefix plus one each for author and permlink. | ||
| Assert.Equal(3, path!.Split('/').Length); | ||
| Assert.StartsWith("post-tips/", path); | ||
| Assert.DoesNotContain("?", path); | ||
| Assert.DoesNotContain("#", path); | ||
|
|
||
| // And the whole thing still resolves to a path under post-tips rather | ||
| // than somewhere else on the upstream host. | ||
| var resolved = new Uri(new Uri("https://upstream.invalid/api/"), path); | ||
| Assert.StartsWith("/api/post-tips/", resolved.AbsolutePath); | ||
| Assert.Equal("", resolved.Query); | ||
| } | ||
|
|
||
| [Theory] | ||
| [InlineData("..", "p")] | ||
| [InlineData("a", "..")] | ||
| [InlineData(".", "p")] | ||
| [InlineData("a", ".")] | ||
| public void DotSegmentsAreRejectedRatherThanEscaped(string author, string permlink) | ||
| { | ||
| // Escaping cannot neutralise these: `.` and `..` are unreserved so | ||
| // EscapeDataString passes them through, and Uri decodes `%2E` back to `.` | ||
| // before removing dot segments, so hand-encoding them resolves upward too. | ||
| // Verified against Uri directly: | ||
| Assert.Equal("/api/p", new Uri(new Uri("https://upstream.invalid/api/"), "post-tips/../p").AbsolutePath); | ||
| Assert.Equal("/api/p", new Uri(new Uri("https://upstream.invalid/api/"), "post-tips/%2E%2E/p").AbsolutePath); | ||
|
|
||
| Assert.Null(PrivateApi.PostTipsPath(author, permlink)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void EmptyAndMissingValuesStayInsideTheirSegment() | ||
| { | ||
| // Missing route params and absent body keys interpolate as "undefined"; | ||
| // both must remain a single inert segment. | ||
| Assert.Equal("post-tips/undefined/undefined", PrivateApi.PostTipsPath("undefined", "undefined")); | ||
| Assert.Equal(3, PrivateApi.PostTipsPath("", "")!.Split('/').Length); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the parity harness compares this build with the legacy Node image, Useful? React with 👍 / 👎. |
||
|
|
||
| // ---- Private Api (POST) ---- | ||
| app.MapPost("/private-api/comment-history", PrivateApi.CommentHistory); | ||
|
|
||
| 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; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.