using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Api;
/// Supplies the bearer token for API calls, refreshing it when needed.
///
/// An abstraction because token lifetime is the auth layer's problem, not the API client's. The
/// client asks for a token per request and never caches one, so a refresh that happens mid-session is
/// invisible here rather than something every call site has to remember to handle.
///
public interface IAccessTokenProvider
{
/// Returns a currently-valid access token.
ValueTask GetAccessTokenAsync(CancellationToken cancellationToken);
}
///
/// The account calls: who am I, and publish my first key.
///
///
/// Separated for the same reason as . What the session layer does with these is
/// decide between enrolling and unlocking, and persist the result so the next launch needs no network;
/// testing that against a stubbed HTTP transport would prove the right bytes were sent and nothing
/// about the decision.
///
public interface IAccountApi
{
/// Reads the caller's profile, unlock material and reachable vaults.
Task GetMeAsync(CancellationToken cancellationToken);
/// Publishes the caller's first identity key and creates their personal vault.
Task EnrollAsync(EnrollmentRequest request, CancellationToken cancellationToken);
///
/// Registers a device key against an account that is already enrolled.
///
///
/// On the interface rather than only on the client, because the session layer decides when to
/// offer this — after an unlock, never before — and that decision is worth testing without HTTP.
///
Task RegisterDeviceAsync(
RegisterDeviceRequest request,
CancellationToken cancellationToken);
///
/// Withdraws a device key, so that machine can no longer unlock without the passphrase.
///
///
/// Whether the account had that device. False means it did not, which a caller withdrawing its own
/// device should treat as having arrived rather than as a failure — another machine may have revoked it
/// first, and the goal state is the same either way.
///
Task RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken);
}
///
/// Teams, their members, and the vaults they own.
///
///
/// Separated from although the two are used together, because they are
/// different kinds of act. Everything here changes what the server will serve and can be
/// performed by anything holding a token. Issuing a grant needs a vault key, which only an unlocked
/// session has — so the two live behind different interfaces and are tested against different fakes.
///
public interface ITeamApi
{
/// Lists the teams the caller belongs to.
Task> ListTeamsAsync(CancellationToken cancellationToken);
/// Creates a team, with the caller as its owner.
Task CreateTeamAsync(CreateTeamRequest request, CancellationToken cancellationToken);
/// Renames a team, or changes its description.
Task UpdateTeamAsync(
Guid teamId,
UpdateTeamRequest request,
CancellationToken cancellationToken);
///
/// Archives a team. Refused while it still owns vaults.
///
///
/// Whether there was a team to archive. False means there was not, which a caller driving towards
/// "that team is gone" should treat as having arrived.
///
Task ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken);
/// Hands ownership to another member, demoting the outgoing owner to admin.
Task TransferTeamOwnershipAsync(
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken);
/// Lists a team's members.
Task> ListTeamMembersAsync(
Guid teamId,
CancellationToken cancellationToken);
/// Adds a member to a team.
Task AddTeamMemberAsync(
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken);
/// Changes a member's role.
Task ChangeTeamMemberRoleAsync(
Guid teamId,
Guid userId,
ChangeTeamMemberRoleRequest request,
CancellationToken cancellationToken);
///
/// Removes a member, revoking every vault key grant they hold from this team.
///
///
/// Whether the team had that member. False means it did not, which a caller driving towards
/// "they are not in this team" should treat as having arrived.
///
Task RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken);
/// Lists a team's invitations, including the ones already dealt with.
Task> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken);
/// Invites an email address to a team.
Task CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken);
///
/// Withdraws an invitation that has not been taken up.
///
///
/// Whether there was a live invitation to withdraw. False covers one that was never there and one
/// already claimed — a claimed invitation is a membership now, and removing a member is a different
/// operation with different consequences.
///
Task RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken);
/// Creates a vault owned by a team, with the creator's key grant.
Task CreateTeamVaultAsync(
Guid teamId,
CreateTeamVaultRequest request,
CancellationToken cancellationToken);
}
///
/// The public-key directory and the log that makes it checkable.
///
///
/// The two belong together and are used together: a directory answer is a claim, and the key log is
/// what turns it into something a client can verify. Splitting them would make it possible to build a
/// caller that reads one and not the other, which is precisely the mistake — see ADR 0001 — that
/// undoes end-to-end encryption entirely.
///
public interface IDirectoryApi
{
/// Looks a user up by exact email address. There is no search.
Task> LookupByEmailAsync(
string email,
CancellationToken cancellationToken);
/// Looks up an account the caller shares a team with.
Task LookupByIdAsync(Guid userId, CancellationToken cancellationToken);
/// Reads entries after a sequence, with the log's current head.
Task ReadKeyLogAsync(
long afterSequence,
int? limit,
CancellationToken cancellationToken);
}
///
/// Vault key grants: who can open a vault, and the record of who let them.
///
///
/// The wrapped key and the signature are produced by an unlocked session and are opaque to everything
/// between it and the recipient, this interface included.
///
public interface IVaultGrantApi
{
///
/// Renames a vault.
///
///
/// Here rather than on because the subject is a vault, and because the vaults
/// screen that calls it is about vaults — the team a vault belongs to is behind it, and renaming one
/// is not an operation on the team. The server renames that team with it where it owns nothing else.
///
Task RenameVaultAsync(
Guid vaultId,
UpdateVaultRequest request,
CancellationToken cancellationToken);
///
/// Deletes a vault and withdraws every key to it.
///
/// Whether there was a vault to delete.
///
///
/// False rather than an exception for a vault that is already gone, exactly as
/// answers about a grant: a caller driving towards "this vault is
/// no longer there" has arrived, and two admins deleting the same vault must not leave the slower one
/// looking at an error about something that happened.
///
///
/// What it does not do is reach anybody's machine. A member who synced before this holds their copy
/// afterwards — see ADR 0001 on revocation — and the interface that offers this has to say so.
///
///
Task DeleteVaultAsync(Guid vaultId, CancellationToken cancellationToken);
/// Lists who holds a key to this vault.
Task ListVaultGrantsAsync(Guid vaultId, CancellationToken cancellationToken);
/// Records a vault key wrapped to another member.
Task IssueVaultGrantAsync(
Guid vaultId,
IssueVaultGrantRequest request,
CancellationToken cancellationToken);
///
/// Advances this vault to a fresh key generation, wrapped to the caller.
///
/// The vault at its new generation, with the caller's grants for the earlier ones.
///
/// The key is generated by the caller and sealed to itself; the server contributes the moment it
/// takes effect, which is the one part a client cannot decide on its own. Wrapping the new
/// generation to everybody else is a separate act, and it is the caller's — see
/// .
///
Task RekeyVaultAsync(
Guid vaultId,
RekeyVaultRequest request,
CancellationToken cancellationToken);
///
/// Withdraws a member's key to this vault.
///
/// Whether there was a live grant to withdraw.
///
/// Blocks future reads and nothing else. Whatever they have already pulled is on their machine;
/// the remediation for a departure is rotating the SSH credential. See ADR 0001.
///
Task RevokeVaultGrantAsync(
Guid vaultId,
Guid userId,
CancellationToken cancellationToken);
}
///
/// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP.
///
///
/// The sync engine's job is a conflict-resolution policy, and testing a policy against a stubbed
/// transport only proves that the right bytes were sent. Behind this interface the suite runs an
/// in-memory server that enforces the real version checks, assigns real change sequences and issues
/// real cursors — so a test can assert what happens when two clients edit one host, which is the
/// question that actually matters.
///
public interface ISyncApi
{
/// Reads vault changes after a cursor.
Task SyncPullAsync(
Guid vaultId,
SyncPullRequest request,
CancellationToken cancellationToken);
/// Applies a batch of vault changes.
Task SyncPushAsync(
Guid vaultId,
SyncPushRequest request,
CancellationToken cancellationToken);
}
///
/// The typed client for one DodoSSH server.
///
///
///
/// Everything goes through DodoSSH.Contracts and its source-generated serialiser, which is the
/// actual contract between the two sides — not the OpenAPI document. Requests are written with
/// StrictRequestOptions on the server and read here with ResponseOptions, so an older
/// client tolerates a newer server's extra fields instead of failing on them.
///
///
/// Discovery is unauthenticated by necessity: a client has to learn how to authenticate before it can.
/// Everything else carries a bearer token.
///
///
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
: IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi
{
private const string MetaPath = "/api/v1/meta";
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
private const string MePath = "/api/v1/me";
private const string EnrollmentPath = "/api/v1/me/enrollment";
private const string DevicesPath = "/api/v1/me/devices";
private const string DirectoryPath = "/api/v1/directory";
private const string KeyLogPath = "/api/v1/keylog";
private const string TeamsPath = "/api/v1/teams";
///
/// Reads the server's capabilities, versions and limits.
///
///
/// Unauthenticated, and the replacement for URL-based API versioning: when client and server
/// upgrade independently — normal for self-hosted software — a client has to ask what this
/// particular server supports rather than assume. See ADR 0002.
///
public Task GetMetaAsync(CancellationToken cancellationToken) =>
GetAnonymousAsync(MetaPath, DodoSshJsonContext.Default.MetaResponse, cancellationToken);
///
/// Reads everything needed to begin authenticating.
///
///
/// This is the onboarding story: the user types one server URL and the client discovers the OIDC
/// authority, the client id, the scopes and the relay from it.
///
public Task GetConfigurationAsync(CancellationToken cancellationToken) =>
GetAnonymousAsync(
ConfigurationPath,
DodoSshJsonContext.Default.DodoSshConfiguration,
cancellationToken);
///
/// Reads the caller's profile, unlock material and reachable vaults.
///
///
/// The first authenticated call a client makes, and the only one that works before enrollment. It
/// also provisions the account, so its UserId is available before enrolling — which matters,
/// because the secret bundle's AAD binds to that id and therefore cannot be built any earlier.
///
public Task GetMeAsync(CancellationToken cancellationToken) =>
SendAsync(HttpMethod.Get, MePath, null, DodoSshJsonContext.Default.MeResponse, cancellationToken);
/// Publishes the caller's first identity key and creates their personal vault.
public Task EnrollAsync(
EnrollmentRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
EnrollmentPath,
JsonContent.Create(request, DodoSshJsonContext.Default.EnrollmentRequest),
DodoSshJsonContext.Default.EnrollmentResponse,
cancellationToken);
/// Registers a device key against an already-enrolled account.
///
/// Requires an unlocked vault, because the wrap can only be produced by something holding the secret
/// bundle. That is also what proves possession to the server, which is why there is no challenge here.
///
public Task RegisterDeviceAsync(
RegisterDeviceRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
DevicesPath,
JsonContent.Create(request, DodoSshJsonContext.Default.RegisterDeviceRequest),
DodoSshJsonContext.Default.RegisterDeviceResponse,
cancellationToken);
///
public Task RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"{DevicesPath}/{deviceId}"),
cancellationToken);
/// Reads vault changes after a cursor.
///
/// A POST despite being a read: the filters live in the body, cursors are opaque, and no caching is
/// wanted.
///
public Task SyncPullAsync(
Guid vaultId,
SyncPullRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
$"/api/v1/vaults/{vaultId}/sync/pull",
JsonContent.Create(request, DodoSshJsonContext.Default.SyncPullRequest),
DodoSshJsonContext.Default.SyncPullResponse,
cancellationToken);
///
/// Applies a batch of vault changes.
///
///
/// Succeeds with per-operation status even when individual operations failed, so one stale item
/// cannot block everything else a client queued while offline. Callers must inspect
/// SyncPushResult.Status rather than treating a 200 as everything having applied.
///
public Task SyncPushAsync(
Guid vaultId,
SyncPushRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
$"/api/v1/vaults/{vaultId}/sync/push",
JsonContent.Create(request, DodoSshJsonContext.Default.SyncPushRequest),
DodoSshJsonContext.Default.SyncPushResponse,
cancellationToken);
///
public Task> ListTeamsAsync(CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
TeamsPath,
null,
DodoSshJsonContext.Default.IReadOnlyListTeamSummary,
cancellationToken);
///
public Task CreateTeamAsync(
CreateTeamRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
TeamsPath,
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamRequest),
DodoSshJsonContext.Default.TeamSummary,
cancellationToken);
///
public Task UpdateTeamAsync(
Guid teamId,
UpdateTeamRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Put,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}"),
JsonContent.Create(request, DodoSshJsonContext.Default.UpdateTeamRequest),
DodoSshJsonContext.Default.TeamSummary,
cancellationToken);
///
public Task ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}"),
cancellationToken);
///
public Task TransferTeamOwnershipAsync(
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken) =>
SendNoContentAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/owner"),
JsonContent.Create(request, DodoSshJsonContext.Default.TransferTeamOwnershipRequest),
cancellationToken);
///
public Task> ListTeamMembersAsync(
Guid teamId,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"),
null,
DodoSshJsonContext.Default.IReadOnlyListTeamMemberSummary,
cancellationToken);
///
public Task AddTeamMemberAsync(
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"),
JsonContent.Create(request, DodoSshJsonContext.Default.AddTeamMemberRequest),
DodoSshJsonContext.Default.TeamMemberSummary,
cancellationToken);
///
public Task ChangeTeamMemberRoleAsync(
Guid teamId,
Guid userId,
ChangeTeamMemberRoleRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Put,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}/role"),
JsonContent.Create(request, DodoSshJsonContext.Default.ChangeTeamMemberRoleRequest),
DodoSshJsonContext.Default.TeamMemberSummary,
cancellationToken);
///
public Task RemoveTeamMemberAsync(
Guid teamId,
Guid userId,
CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"),
cancellationToken);
///
public Task> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"),
null,
DodoSshJsonContext.Default.IReadOnlyListTeamInvitationSummary,
cancellationToken);
///
public Task CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"),
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamInvitationRequest),
DodoSshJsonContext.Default.TeamInvitationSummary,
cancellationToken);
///
public Task RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(
CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations/{invitationId}"),
cancellationToken);
///
public Task CreateTeamVaultAsync(
Guid teamId,
CreateTeamVaultRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/vaults"),
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamVaultRequest),
DodoSshJsonContext.Default.VaultSummary,
cancellationToken);
///
/// Looks a user up by exact email address.
///
///
/// The address is escaped into the query string, which is the one place in this client where a
/// value a user typed reaches a URL. rather than string
/// concatenation: an unescaped & or # in an address would silently become a
/// lookup for something else.
///
public Task> LookupByEmailAsync(
string email,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(email);
return SendAsync(
HttpMethod.Get,
$"{DirectoryPath}?email={Uri.EscapeDataString(email)}",
null,
DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry,
cancellationToken);
}
///
public async Task LookupByIdAsync(
Guid userId,
CancellationToken cancellationToken)
{
var entries = await SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"{DirectoryPath}?userId={userId}"),
null,
DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry,
cancellationToken)
.ConfigureAwait(false);
return entries.Count == 0 ? null : entries[0];
}
///
public Task ReadKeyLogAsync(
long afterSequence,
int? limit,
CancellationToken cancellationToken)
{
var path = limit is null
? string.Create(CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}")
: string.Create(
CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}&limit={limit}");
return SendAsync(
HttpMethod.Get, path, null, DodoSshJsonContext.Default.KeyLogPage, cancellationToken);
}
///
public Task RenameVaultAsync(
Guid vaultId,
UpdateVaultRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Put,
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}"),
JsonContent.Create(request, DodoSshJsonContext.Default.UpdateVaultRequest),
DodoSshJsonContext.Default.VaultSummary,
cancellationToken);
///
public Task DeleteVaultAsync(Guid vaultId, CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}"),
cancellationToken);
///
public Task ListVaultGrantsAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"),
null,
DodoSshJsonContext.Default.VaultGrantsResponse,
cancellationToken);
///
public Task IssueVaultGrantAsync(
Guid vaultId,
IssueVaultGrantRequest request,
CancellationToken cancellationToken) =>
SendNoContentAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"),
JsonContent.Create(request, DodoSshJsonContext.Default.IssueVaultGrantRequest),
cancellationToken);
///
public Task RekeyVaultAsync(
Guid vaultId,
RekeyVaultRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/rekey"),
JsonContent.Create(request, DodoSshJsonContext.Default.RekeyVaultRequest),
DodoSshJsonContext.Default.VaultSummary,
cancellationToken);
///
public Task RevokeVaultGrantAsync(
Guid vaultId,
Guid userId,
CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants/{userId}"),
cancellationToken);
private async Task GetAnonymousAsync(
string path,
System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false);
}
private async Task SendAsync(
HttpMethod method,
string path,
HttpContent? content,
System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, path) { Content = content };
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false);
}
///
/// Sends a request whose success carries no body.
///
///
/// Its own path for the reason gives, minus the 404: a grant that will
/// not be recorded, or an ownership transfer that will not happen, is a failure with a problem
/// document behind it, so there is nothing here to translate into a return value.
///
private async Task SendNoContentAsync(
HttpMethod method,
string path,
HttpContent? content,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, path) { Content = content };
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
throw DodoSshApiException.FromResponse(response.StatusCode, body);
}
}
///
/// Sends a delete whose success carries no body.
///
/// True for a 2xx, false for a 404; anything else throws.
///
/// Its own path rather than with some empty response type, because the two
/// disagree about what a missing body means. Everywhere else a 200 with nothing in it is a server bug
/// worth an exception; here it is the answer.
///
private async Task DeleteAsync(string path, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, path);
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return false;
}
if (!response.IsSuccessStatusCode)
{
var body = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
throw DodoSshApiException.FromResponse(response.StatusCode, body);
}
return true;
}
private async Task SendCoreAsync(
HttpRequestMessage request,
System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo,
CancellationToken cancellationToken)
{
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
throw DodoSshApiException.FromResponse(response.StatusCode, body);
}
var value = await response.Content
.ReadFromJsonAsync(typeInfo, cancellationToken)
.ConfigureAwait(false);
// A 200 with a null body is a server bug, but it must not surface as a NullReferenceException
// three frames further up where the cause is invisible.
return value ?? throw new DodoSshApiException(
HttpStatusCode.OK,
null,
$"The server returned an empty body where a {typeof(T).Name} was expected.");
}
}