Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
24 changes: 24 additions & 0 deletions Services/Caching/CacheService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,30 @@ public string BuildCacheKey(string endpoint, IQueryCollection query, string? use
? el.GetString()
: null;

/// <summary>Honours an explicit <c>?{InvalidateParam}=true</c> on the request.</summary>
public void ApplyRequestedInvalidation(HttpContext context)
{
if (!context.Request.Query.ContainsKey(InvalidateParam)) return;
if (!string.Equals(context.Request.Query[InvalidateParam], "true", StringComparison.OrdinalIgnoreCase))
return;

InvalidateScopedOrAll(context);
}

/// <summary>
/// Invalidates just the caller's scope when one is present, otherwise everything — without a scope
/// there is no way to know which entries a write affected.
/// </summary>
public void InvalidateScopedOrAll(HttpContext context)
{
var scopeValue = !string.IsNullOrEmpty(ScopeParam)
? context.Request.Query[ScopeParam].ToString()
: "";

if (!string.IsNullOrEmpty(scopeValue)) InvalidateByScope(scopeValue);
else InvalidateAll();
}

/// <summary>
/// Get the TTL for a given endpoint.
/// </summary>
Expand Down
4 changes: 2 additions & 2 deletions Services/Configuration/EgressLimitSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ public class EgressLimitSettings

/// <summary>
/// Days of bucketed rows to retain in the table before a periodic purge deletes them. Default 7.
/// Floored at 1. Env override: <c>CRAFT_API_EGRESS_RETENTION_DAYS</c>. Does not affect the local file,
/// which only ever holds the current UTC day.
/// Floored at 1. Env override: <c>CRAFT_API_EGRESS_RETENTION_DAYS</c>. Also the number of days (today
/// included) the local ledger file keeps.
/// </summary>
public int RetentionDays { get; set; } = 7;

Expand Down
26 changes: 26 additions & 0 deletions Services/Endpoints/CraftEndpointAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ public enum EndpointDispatch
Direct,
}

/// <summary>
/// Whether a native endpoint's GET responses go through the response cache (<c>App:Cache</c>).
/// </summary>
public enum EndpointCaching
{
/// <summary>
/// The PowerShell path's rule: GETs to a route named <c>List…</c> are cached, the name being the
/// endpoint's promise that it is a side-effect-free read. The default, so native and PowerShell
/// endpoints of the same name cache alike.
/// </summary>
Convention,

/// <summary>Every GET is cached, whatever the route is called — a read that isn't named List….</summary>
Cached,

/// <summary>Never cached: a read whose answer must be live (a status view, a log tail).</summary>
Uncached,
}

/// <summary>
/// Marks a class as a native endpoint and declares its route and metadata. The C# counterpart of the
/// PowerShell convention where <c>Invoke-GetIPInfo</c> becomes <c>/API/GetIPInfo</c> and the
Expand Down Expand Up @@ -87,4 +106,11 @@ public CraftEndpointAttribute(string route)
/// unaffected until they ship one.
/// </summary>
public EndpointDispatch Dispatch { get; init; } = EndpointDispatch.Central;

/// <summary>
/// Whether this endpoint's GET responses are cached. Defaults to <see cref="EndpointCaching.Convention"/>
/// (GETs to List… routes), as the PowerShell path does; <c>App:Cache:ExcludedEndpoints</c> and the rest of
/// the cache policy still apply on top of it.
/// </summary>
public EndpointCaching Caching { get; init; } = EndpointCaching.Convention;
}
20 changes: 20 additions & 0 deletions Services/Endpoints/CraftResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text;
using System.Text.Json;
using Craft.Hosting;
using Craft.Services;

namespace Craft.Endpoints;

Expand Down Expand Up @@ -105,6 +106,25 @@ public static CraftResult Problem(int status, string message) =>
/// </summary>
public static CraftResult Handled { get; } = new(Kind.Handled, 0, null, null);

/// <summary>
/// This result as the response cache stores it, or null when it can't be: only a JSON body with a 2xx or 3xx
/// status, as the PowerShell path caches. Bytes, streams and endpoint-written responses are never cached.
/// </summary>
internal ScriptResult? ToCacheable() =>
_kind == Kind.Json && StatusCode is >= 200 and < 400
? new ScriptResult
{
StatusCode = StatusCode,
Body = _text ?? "null",
ContentType = ContentType,
Headers = Headers?.ToDictionary(h => h.Key, h => h.Value),
}
: null;

/// <summary>A cached response, written back exactly as it was stored.</summary>
internal static CraftResult FromCached(ScriptResult cached) =>
new(Kind.Json, cached.StatusCode, cached.ContentType ?? "application/json", cached.Headers, text: cached.Body);

/// <summary>Writes this result to the response.</summary>
internal async Task WriteAsync(HttpContext context, CancellationToken ct)
{
Expand Down
2 changes: 1 addition & 1 deletion Services/Hosting/ApiEgressLimiterMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public async Task InvokeAsync(HttpContext context)
// concurrency cap, the overshoot is bounded to (concurrency × largest response).
if (_ledger.ShouldReject())
{
_ledger.RecordShed(appId, context.GetRouteValue("endpoint") as string);
_ledger.RecordShed(appId, context.GetRouteValue("endpoint") as string, RateLimitPartitionKey.ClientAddress(context));
await RejectAsync(context);
return;
}
Expand Down
4 changes: 2 additions & 2 deletions Services/Hosting/ApiEgressWireCounterMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ public async Task InvokeAsync(HttpContext context)
{
context.Response.Body = original;
if (context.Items.TryGetValue(ChargeItemKey, out var charge) && charge is string appId && appId.Length > 0)
_ledger.Record(counting.BytesWritten, appId, ResolveEndpoint(context), IsCacheHit(context), context.Response.StatusCode);
_ledger.Record(counting.BytesWritten, appId, ResolveEndpoint(context), IsCacheHit(context), context.Response.StatusCode, RateLimitPartitionKey.ClientAddress(context), context.Response.Headers.ContentEncoding.ToString());
else if (context.Items.ContainsKey(InteractiveItemKey))
_ledger.RecordInteractive(counting.BytesWritten, ResolveEndpoint(context), IsCacheHit(context), context.Response.StatusCode);
_ledger.RecordInteractive(counting.BytesWritten, ResolveEndpoint(context), IsCacheHit(context), context.Response.StatusCode, RateLimitPartitionKey.ClientAddress(context), context.Response.Headers.ContentEncoding.ToString());
}
}

Expand Down
Loading
Loading