feat(google-calendar): OAuth device flow, refresh and revoke client
Spec §3: DeviceAuthorization/PollStatus shapes, Success carries the access token, 5xx without error is a transport error, RefreshAsync returns AccessToken, RevokeAsync returns the failure reason (null on success).
This commit is contained in:
@@ -110,16 +110,22 @@ and GOOGLE_CLIENT_SECRET`). If only one of the two is set, it logs a `Warning` i
|
||||
|
||||
### 3. OAuth client — `GoogleOAuthClient`
|
||||
|
||||
- `StartDeviceFlowAsync(ct) → DeviceCode(DeviceCode, UserCode, VerificationUrl, ExpiresAt, Interval)`. A non-2xx
|
||||
response throws `GoogleCalendarException` with Google's `error`/`error_description`.
|
||||
- `PollAsync(deviceCode, ct) → PollResult`: `Success(refreshToken, scope)`, `Pending`, `SlowDown`, `Denied`, `Expired`,
|
||||
`Failed(message)`. **The mapping reads the JSON `error` field whatever the status code.** Google documents 428 for
|
||||
`authorization_pending` and 403 for `slow_down`/`access_denied`; the reference only looked at 400.
|
||||
- `StartDeviceFlowAsync(ct) → DeviceAuthorization(DeviceCode, UserCode, VerificationUrl, ExpiresAt, Interval)`
|
||||
(`verification_url`, or `verification_uri` when Google sends that; the interval defaults to 5 seconds). A non-2xx
|
||||
response throws `GoogleCalendarException` with Google's `error`/`error_description`, never the client secret.
|
||||
- `PollAsync(deviceCode, ct) → PollResult`, one record with a `PollStatus`: `Success` (refresh token, scope, access
|
||||
token and its expiry; the connect flow uses the access token for `calendars/primary`), `Pending`, `SlowDown`,
|
||||
`Denied`, `Expired`, `Failed(message)`. **The mapping reads the JSON `error` field whatever the status code.** Google
|
||||
documents 428 for `authorization_pending` and 403 for `slow_down`/`access_denied`; the reference only looked at 400.
|
||||
A success without `refresh_token` is `Failed("Google returned no refresh token. Remove the app at
|
||||
https://myaccount.google.com/permissions and run /calendar_connect again.")`.
|
||||
- `RefreshAsync(refreshToken, ct) → (accessToken, expiresAt)`. `invalid_grant` throws
|
||||
https://myaccount.google.com/permissions and run /calendar_connect again.")`. A 5xx response without an `error`
|
||||
field throws `HttpRequestException` and counts as a transport error in the flow (§5). `PollResult.ToString()` never
|
||||
shows tokens.
|
||||
- `RefreshAsync(refreshToken, ct) → AccessToken(Token, ExpiresAt)`. `invalid_grant` throws
|
||||
`GoogleCalendarException(NotConnected: true, "Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.")`.
|
||||
- `RevokeAsync(token, ct)`: best effort; failures are reported but don't stop a disconnect.
|
||||
Other errors throw `GoogleCalendarException("Google token refresh failed: <error>[: <description>]")`.
|
||||
- `RevokeAsync(token, ct) → string?`: best effort; `null` when Google revoked the token, else the failure reason
|
||||
(Google's error, `HTTP <status>` or the transport error). Failures are reported but don't stop a disconnect.
|
||||
|
||||
### 4. Connections — `GoogleCalendarConnections` (process-wide) and `GoogleCalendarConnection` (per chat)
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
internal sealed record DeviceAuthorization(string DeviceCode, string UserCode, string VerificationUrl, DateTimeOffset ExpiresAt, TimeSpan Interval);
|
||||
|
||||
internal enum PollStatus { Success, Pending, SlowDown, Denied, Expired, Failed }
|
||||
|
||||
/// <summary>One poll of the token endpoint. <see cref="ToString"/> never shows tokens.</summary>
|
||||
internal sealed record PollResult(PollStatus Status, string? RefreshToken = null, string? Scope = null, string? AccessToken = null,
|
||||
DateTimeOffset AccessTokenExpiresAt = default, string? Message = null)
|
||||
{
|
||||
public static readonly PollResult Pending = new(PollStatus.Pending);
|
||||
public static readonly PollResult SlowDown = new(PollStatus.SlowDown);
|
||||
public static readonly PollResult Denied = new(PollStatus.Denied);
|
||||
public static readonly PollResult Expired = new(PollStatus.Expired);
|
||||
|
||||
public static PollResult Succeeded(string refreshToken, string? scope, string accessToken, DateTimeOffset expiresAt) =>
|
||||
new(PollStatus.Success, refreshToken, scope, accessToken, expiresAt);
|
||||
|
||||
public static PollResult Failed(string message) => new(PollStatus.Failed, Message: message);
|
||||
|
||||
public override string ToString() => Message is null ? Status.ToString() : $"{Status}: {Message}";
|
||||
}
|
||||
|
||||
internal sealed record AccessToken(string Token, DateTimeOffset ExpiresAt)
|
||||
{
|
||||
public override string ToString() => $"AccessToken {{ ExpiresAt = {ExpiresAt:O} }}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Google's OAuth 2.0 device flow for limited-input devices, refresh and revoke, over raw HTTP. Error mapping reads
|
||||
/// the JSON <c>error</c> field whatever the status code (Google documents 428 and 403 besides 400). Messages never
|
||||
/// contain tokens or the client secret.
|
||||
/// </summary>
|
||||
internal sealed class GoogleOAuthClient(HttpClient http, string clientId, string clientSecret, TimeProvider time)
|
||||
{
|
||||
public const string DeviceCodeUrl = "https://oauth2.googleapis.com/device/code";
|
||||
public const string TokenUrl = "https://oauth2.googleapis.com/token";
|
||||
public const string RevokeUrl = "https://oauth2.googleapis.com/revoke";
|
||||
public const string CalendarScope = "https://www.googleapis.com/auth/calendar";
|
||||
public const string DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
public async Task<DeviceAuthorization> StartDeviceFlowAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var (status, json) = await PostAsync(DeviceCodeUrl, [new("client_id", clientId), new("scope", CalendarScope)], cancellationToken).ConfigureAwait(false);
|
||||
if (!IsSuccess(status) || json is null || Str(json, "device_code") is not { } deviceCode || Str(json, "user_code") is not { } userCode)
|
||||
throw new GoogleCalendarException($"Google refused the device code request: {Describe(json, status)}");
|
||||
var url = Str(json, "verification_url") ?? Str(json, "verification_uri") ?? "https://www.google.com/device";
|
||||
var expiresIn = Int(json, "expires_in") ?? 1800;
|
||||
var interval = Math.Max(1, Int(json, "interval") ?? 5);
|
||||
return new DeviceAuthorization(deviceCode, userCode, url, time.GetUtcNow().AddSeconds(expiresIn), TimeSpan.FromSeconds(interval));
|
||||
}
|
||||
|
||||
public async Task<PollResult> PollAsync(string deviceCode, CancellationToken cancellationToken)
|
||||
{
|
||||
var (status, json) = await PostAsync(TokenUrl,
|
||||
[new("client_id", clientId), new("client_secret", clientSecret), new("device_code", deviceCode), new("grant_type", DeviceGrantType)],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (json is not null && Str(json, "error") is { } error)
|
||||
{
|
||||
return error switch
|
||||
{
|
||||
"authorization_pending" => PollResult.Pending,
|
||||
"slow_down" => PollResult.SlowDown,
|
||||
"access_denied" => PollResult.Denied,
|
||||
"expired_token" => PollResult.Expired,
|
||||
_ => PollResult.Failed(Describe(json, status)),
|
||||
};
|
||||
}
|
||||
if ((int)status >= 500) throw new HttpRequestException($"Google returned HTTP {(int)status}.");
|
||||
if (!IsSuccess(status) || json is null) return PollResult.Failed(Describe(json, status));
|
||||
if (Str(json, "refresh_token") is not { Length: > 0 } refreshToken) return PollResult.Failed(GoogleCalendarMessages.NoRefreshToken);
|
||||
return PollResult.Succeeded(refreshToken, Str(json, "scope"), Str(json, "access_token") ?? "",
|
||||
time.GetUtcNow().AddSeconds(Int(json, "expires_in") ?? 3600));
|
||||
}
|
||||
|
||||
public async Task<AccessToken> RefreshAsync(string refreshToken, CancellationToken cancellationToken)
|
||||
{
|
||||
var (status, json) = await PostAsync(TokenUrl,
|
||||
[new("client_id", clientId), new("client_secret", clientSecret), new("refresh_token", refreshToken), new("grant_type", "refresh_token")],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (json is not null && Str(json, "error") == "invalid_grant")
|
||||
throw new GoogleCalendarException(GoogleCalendarMessages.TokenRejected, notConnected: true);
|
||||
if (!IsSuccess(status) || json is null || Str(json, "access_token") is not { Length: > 0 } accessToken)
|
||||
throw new GoogleCalendarException($"Google token refresh failed: {Describe(json, status)}");
|
||||
return new AccessToken(accessToken, time.GetUtcNow().AddSeconds(Int(json, "expires_in") ?? 3600));
|
||||
}
|
||||
|
||||
/// <summary>Best effort. Null when Google revoked the token, else the reason.</summary>
|
||||
public async Task<string?> RevokeAsync(string token, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (status, json) = await PostAsync(RevokeUrl, [new("token", token)], cancellationToken).ConfigureAwait(false);
|
||||
return IsSuccess(status) ? null : Describe(json, status);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return ex.Message;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return ex.Message; // HttpClient timeout
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(HttpStatusCode Status, JsonObject? Json)> PostAsync(string url, KeyValuePair<string, string>[] form, CancellationToken cancellationToken)
|
||||
{
|
||||
using var content = new FormUrlEncodedContent(form);
|
||||
using var response = await http.PostAsync(url, content, cancellationToken).ConfigureAwait(false);
|
||||
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
JsonObject? json = null;
|
||||
try { json = JsonNode.Parse(text) as JsonObject; }
|
||||
catch (System.Text.Json.JsonException) { }
|
||||
return (response.StatusCode, json);
|
||||
}
|
||||
|
||||
private static bool IsSuccess(HttpStatusCode status) => (int)status is >= 200 and < 300;
|
||||
|
||||
private static string Describe(JsonObject? json, HttpStatusCode status)
|
||||
{
|
||||
var error = json is null ? null : Str(json, "error");
|
||||
var description = json is null ? null : Str(json, "error_description");
|
||||
return (error, description) switch
|
||||
{
|
||||
({ } e, { } d) => $"{e}: {d}",
|
||||
({ } e, null) => e,
|
||||
_ => $"HTTP {(int)status}",
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Str(JsonObject json, string name) =>
|
||||
json[name] is JsonValue value && value.TryGetValue<string>(out var s) ? s : null;
|
||||
|
||||
private static int? Int(JsonObject json, string name) => json[name] switch
|
||||
{
|
||||
JsonValue v when v.TryGetValue<int>(out var i) => i,
|
||||
JsonValue v when v.TryGetValue<string>(out var s) && int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p) => p,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
|
||||
|
||||
public class GoogleOAuthClientTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 9, 26, 8, 0, 0, TimeSpan.Zero);
|
||||
private readonly FakeTimeProvider _time = new(Now);
|
||||
private readonly GoogleFakeHttp _http;
|
||||
private readonly GoogleOAuthClient _client;
|
||||
|
||||
public GoogleOAuthClientTests()
|
||||
{
|
||||
_http = new GoogleFakeHttp(_time);
|
||||
_client = new GoogleOAuthClient(_http.CreateClient(), "cid", "csecret", _time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_posts_the_client_id_and_the_calendar_scope()
|
||||
{
|
||||
_http.Json("POST /device/code", """{"device_code":"dev-1","user_code":"ABCD-EFGH","verification_url":"https://www.google.com/device","expires_in":1800,"interval":5}""");
|
||||
|
||||
var device = await _client.StartDeviceFlowAsync(CancellationToken.None);
|
||||
|
||||
var request = Assert.Single(_http.Requests);
|
||||
Assert.Equal("https://oauth2.googleapis.com/device/code", request.Uri.ToString());
|
||||
Assert.Equal(new Dictionary<string, string> { ["client_id"] = "cid", ["scope"] = "https://www.googleapis.com/auth/calendar" }, request.Form);
|
||||
Assert.Equal(new DeviceAuthorization("dev-1", "ABCD-EFGH", "https://www.google.com/device", Now.AddSeconds(1800), TimeSpan.FromSeconds(5)), device);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_accepts_verification_uri_and_defaults_the_interval_to_5_seconds()
|
||||
{
|
||||
_http.Json("POST /device/code", """{"device_code":"d","user_code":"U","verification_uri":"https://example.test/device","expires_in":600}""");
|
||||
var device = await _client.StartDeviceFlowAsync(CancellationToken.None);
|
||||
Assert.Equal("https://example.test/device", device.VerificationUrl);
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), device.Interval);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_error_throws_with_googles_error_and_description()
|
||||
{
|
||||
_http.Json("POST /device/code", (HttpStatusCode.Unauthorized, """{"error":"invalid_client","error_description":"The OAuth client was not found."}"""));
|
||||
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _client.StartDeviceFlowAsync(CancellationToken.None));
|
||||
Assert.Contains("invalid_client: The OAuth client was not found.", ex.Message);
|
||||
Assert.DoesNotContain("csecret", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poll_posts_the_device_code_grant()
|
||||
{
|
||||
_http.Json("POST /token", (HttpStatusCode.PreconditionRequired, """{"error":"authorization_pending"}"""));
|
||||
await _client.PollAsync("dev-1", CancellationToken.None);
|
||||
var form = Assert.Single(_http.Requests).Form;
|
||||
Assert.Equal("cid", form["client_id"]);
|
||||
Assert.Equal("csecret", form["client_secret"]);
|
||||
Assert.Equal("dev-1", form["device_code"]);
|
||||
Assert.Equal("urn:ietf:params:oauth:grant-type:device_code", form["grant_type"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(428)]
|
||||
[InlineData(400)]
|
||||
public async Task Poll_maps_authorization_pending_whatever_the_status(int status)
|
||||
{
|
||||
_http.Json("POST /token", ((HttpStatusCode)status, """{"error":"authorization_pending","error_description":"Precondition Required"}"""));
|
||||
Assert.Equal(PollStatus.Pending, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poll_maps_slow_down_at_403()
|
||||
{
|
||||
_http.Json("POST /token", (HttpStatusCode.Forbidden, """{"error":"slow_down"}"""));
|
||||
Assert.Equal(PollStatus.SlowDown, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poll_maps_access_denied()
|
||||
{
|
||||
_http.Json("POST /token", (HttpStatusCode.Forbidden, """{"error":"access_denied","error_description":"Forbidden"}"""));
|
||||
Assert.Equal(PollStatus.Denied, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poll_maps_expired_token()
|
||||
{
|
||||
_http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"expired_token"}"""));
|
||||
Assert.Equal(PollStatus.Expired, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poll_maps_an_unknown_error_to_failed_with_its_text()
|
||||
{
|
||||
_http.Json("POST /token", (HttpStatusCode.Unauthorized, """{"error":"invalid_client","error_description":"Unauthorized"}"""));
|
||||
var result = await _client.PollAsync("dev-1", CancellationToken.None);
|
||||
Assert.Equal(PollStatus.Failed, result.Status);
|
||||
Assert.Equal("invalid_client: Unauthorized", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poll_500_without_an_error_field_is_a_transport_error()
|
||||
{
|
||||
_http.Json("POST /token", (HttpStatusCode.InternalServerError, "<html>oops</html>"));
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => _client.PollAsync("dev-1", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poll_success_returns_the_refresh_and_access_tokens()
|
||||
{
|
||||
_http.Json("POST /token", """{"access_token":"at-1","expires_in":3599,"refresh_token":"rt-1","scope":"https://www.googleapis.com/auth/calendar","token_type":"Bearer"}""");
|
||||
var result = await _client.PollAsync("dev-1", CancellationToken.None);
|
||||
Assert.Equal(PollStatus.Success, result.Status);
|
||||
Assert.Equal("rt-1", result.RefreshToken);
|
||||
Assert.Equal("at-1", result.AccessToken);
|
||||
Assert.Equal("https://www.googleapis.com/auth/calendar", result.Scope);
|
||||
Assert.Equal(Now.AddSeconds(3599), result.AccessTokenExpiresAt);
|
||||
Assert.DoesNotContain("rt-1", result.ToString());
|
||||
Assert.DoesNotContain("at-1", result.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poll_success_without_a_refresh_token_fails_with_the_permissions_hint()
|
||||
{
|
||||
_http.Json("POST /token", """{"access_token":"at-1","expires_in":3599}""");
|
||||
var result = await _client.PollAsync("dev-1", CancellationToken.None);
|
||||
Assert.Equal(PollStatus.Failed, result.Status);
|
||||
Assert.Equal("Google returned no refresh token. Remove the app at https://myaccount.google.com/permissions and run /calendar_connect again.", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_returns_the_access_token_and_its_expiry()
|
||||
{
|
||||
_http.Json("POST /token", """{"access_token":"at-2","expires_in":3600}""");
|
||||
var token = await _client.RefreshAsync("rt-1", CancellationToken.None);
|
||||
Assert.Equal(new AccessToken("at-2", Now.AddSeconds(3600)), token);
|
||||
var form = Assert.Single(_http.Requests).Form;
|
||||
Assert.Equal("refresh_token", form["grant_type"]);
|
||||
Assert.Equal("rt-1", form["refresh_token"]);
|
||||
Assert.Equal("csecret", form["client_secret"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_invalid_grant_throws_not_connected_without_the_token()
|
||||
{
|
||||
_http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"invalid_grant","error_description":"Token has been expired or revoked."}"""));
|
||||
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _client.RefreshAsync("rt-secret-1", CancellationToken.None));
|
||||
Assert.True(ex.NotConnected);
|
||||
Assert.Equal("Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.", ex.Message);
|
||||
Assert.DoesNotContain("rt-secret-1", ex.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_other_errors_throw_with_googles_text()
|
||||
{
|
||||
_http.Json("POST /token", (HttpStatusCode.Unauthorized, """{"error":"invalid_client"}"""));
|
||||
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _client.RefreshAsync("rt-1", CancellationToken.None));
|
||||
Assert.False(ex.NotConnected);
|
||||
Assert.Equal("Google token refresh failed: invalid_client", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_posts_the_token_and_reports_failures()
|
||||
{
|
||||
_http.Json("POST /revoke", (HttpStatusCode.OK, "{}"), (HttpStatusCode.BadRequest, """{"error":"invalid_token"}"""));
|
||||
Assert.Null(await _client.RevokeAsync("rt-1", CancellationToken.None));
|
||||
Assert.Equal("rt-1", _http.To("POST /revoke")[0].Form["token"]);
|
||||
Assert.Equal("invalid_token", await _client.RevokeAsync("rt-1", CancellationToken.None));
|
||||
_http.Fail("POST /revoke", "no route to host");
|
||||
Assert.Equal("no route to host", await _client.RevokeAsync("rt-1", CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
|
||||
|
||||
/// <summary>One request as Google would have seen it, with the fake clock's time.</summary>
|
||||
internal sealed record RecordedRequest(HttpMethod Method, Uri Uri, string? Body, string? Authorization, DateTimeOffset At)
|
||||
{
|
||||
public string Route => $"{Method.Method} {Uri.AbsolutePath}";
|
||||
public IReadOnlyDictionary<string, string> Form => Parse(Body);
|
||||
public IReadOnlyDictionary<string, string> Query => Parse(Uri.Query.TrimStart('?'));
|
||||
public JsonObject Json => JsonNode.Parse(Body ?? "{}")!.AsObject();
|
||||
|
||||
private static Dictionary<string, string> Parse(string? encoded) =>
|
||||
(encoded ?? "").Split('&', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(pair => pair.Split('=', 2))
|
||||
.ToDictionary(p => Decode(p[0]), p => p.Length > 1 ? Decode(p[1]) : "");
|
||||
|
||||
private static string Decode(string s) => Uri.UnescapeDataString(s.Replace('+', ' '));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scripted Google endpoints keyed by <c>"METHOD /path"</c> (host ignored). A route serves its responders in order and
|
||||
/// repeats the last one. Unrouted requests get a 501 so a missing script is loud.
|
||||
/// </summary>
|
||||
internal sealed class GoogleFakeHttp(TimeProvider? time = null) : HttpMessageHandler
|
||||
{
|
||||
private readonly Lock _lock = new();
|
||||
private readonly Dictionary<string, (Func<RecordedRequest, Task<HttpResponseMessage>>[] Responders, int Served)> _routes = new();
|
||||
private readonly List<RecordedRequest> _requests = [];
|
||||
|
||||
public IReadOnlyList<RecordedRequest> Requests { get { lock (_lock) return _requests.ToList(); } }
|
||||
|
||||
public IReadOnlyList<RecordedRequest> To(string route) { lock (_lock) return _requests.Where(r => r.Route == route).ToList(); }
|
||||
|
||||
/// <summary>Replaces the route's script.</summary>
|
||||
public GoogleFakeHttp On(string route, params Func<RecordedRequest, Task<HttpResponseMessage>>[] responders)
|
||||
{
|
||||
lock (_lock) _routes[route] = (responders, 0);
|
||||
return this;
|
||||
}
|
||||
|
||||
public GoogleFakeHttp Json(string route, params (HttpStatusCode Status, string Body)[] responses) =>
|
||||
On(route, responses.Select(r => (Func<RecordedRequest, Task<HttpResponseMessage>>)(_ => Task.FromResult(Response(r.Status, r.Body)))).ToArray());
|
||||
|
||||
public GoogleFakeHttp Json(string route, string body) => Json(route, (HttpStatusCode.OK, body));
|
||||
|
||||
public GoogleFakeHttp Reply(string route, Func<RecordedRequest, (HttpStatusCode Status, string Body)> respond) =>
|
||||
On(route, r =>
|
||||
{
|
||||
var (status, body) = respond(r);
|
||||
return Task.FromResult(Response(status, body));
|
||||
});
|
||||
|
||||
public static Func<RecordedRequest, Task<HttpResponseMessage>> Throws(string message = "network down") =>
|
||||
_ => Task.FromException<HttpResponseMessage>(new HttpRequestException(message));
|
||||
|
||||
public static Func<RecordedRequest, Task<HttpResponseMessage>> Returns(HttpStatusCode status, string body) =>
|
||||
_ => Task.FromResult(Response(status, body));
|
||||
|
||||
public GoogleFakeHttp Fail(string route, string message = "network down") => On(route, Throws(message));
|
||||
|
||||
public static HttpResponseMessage Response(HttpStatusCode status, string body) =>
|
||||
new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
||||
|
||||
public HttpClient CreateClient() => new(this, disposeHandler: false);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
var recorded = new RecordedRequest(request.Method, request.RequestUri!, body, request.Headers.Authorization?.ToString(),
|
||||
(time ?? TimeProvider.System).GetUtcNow());
|
||||
Func<RecordedRequest, Task<HttpResponseMessage>>? responder = null;
|
||||
lock (_lock)
|
||||
{
|
||||
_requests.Add(recorded);
|
||||
if (_routes.TryGetValue(recorded.Route, out var route))
|
||||
{
|
||||
responder = route.Responders[Math.Min(route.Served, route.Responders.Length - 1)];
|
||||
_routes[recorded.Route] = (route.Responders, route.Served + 1);
|
||||
}
|
||||
}
|
||||
if (responder is null)
|
||||
return Response(HttpStatusCode.NotImplemented, $$$"""{"error":{"code":501,"message":"no fake route for {{{recorded.Route}}}"}}""");
|
||||
var response = await responder(recorded);
|
||||
response.RequestMessage = request;
|
||||
return response;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user