Initial project snapshot

This commit is contained in:
2026-04-28 22:29:50 +03:00
commit 8ba0561f4f
365 changed files with 91832 additions and 0 deletions
@@ -0,0 +1,259 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using RemoteAccessPlatform.Windows.Contracts;
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Transport;
public sealed class BackendApiClient : IBackendAuthClient, IBackendOrganizationClient, IBackendResourceClient, IBackendSessionClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _httpClient;
private readonly BackendEndpointOptions _options;
private readonly Func<CancellationToken, Task<string?>> _accessTokenProvider;
private readonly Func<CancellationToken, Task<bool>> _tryRefreshAsync;
public BackendApiClient(
HttpClient httpClient,
BackendEndpointOptions options,
Func<CancellationToken, Task<string?>> accessTokenProvider,
Func<CancellationToken, Task<bool>> tryRefreshAsync)
{
_httpClient = httpClient;
_options = options;
_accessTokenProvider = accessTokenProvider;
_tryRefreshAsync = tryRefreshAsync;
}
public Task<AuthResultDto> LoginAsync(LoginRequest request, CancellationToken cancellationToken) =>
SendAsync<AuthResultDto>(HttpMethod.Post, "auth/login", request, null, false, cancellationToken);
public Task<AuthResultDto> RefreshAsync(RefreshRequest request, CancellationToken cancellationToken) =>
SendAsync<AuthResultDto>(HttpMethod.Post, "auth/refresh", request, null, false, cancellationToken);
public Task RevokeAuthSessionAsync(string userId, string authSessionId, string reason, CancellationToken cancellationToken) =>
SendAsync(HttpMethod.Post, "auth/sessions/revoke", new
{
user_id = userId,
auth_session_id = authSessionId,
reason
}, null, true, cancellationToken);
public async Task<IReadOnlyList<OrganizationDto>> GetOrganizationsAsync(string userId, CancellationToken cancellationToken)
{
OrganizationsResponse response = await SendAsync<OrganizationsResponse>(HttpMethod.Get, "organizations", null, new Dictionary<string, string?>
{
["user_id"] = userId
}, true, cancellationToken);
return response.Organizations;
}
public async Task<IReadOnlyList<ResourceDto>> GetResourcesAsync(string userId, string organizationId, CancellationToken cancellationToken)
{
ResourcesResponse response = await SendAsync<ResourcesResponse>(HttpMethod.Get, "resources", null, new Dictionary<string, string?>
{
["user_id"] = userId,
["organization_id"] = organizationId
}, true, cancellationToken);
return response.Resources;
}
public async Task<IReadOnlyList<RemoteSessionDto>> GetSessionsAsync(string userId, CancellationToken cancellationToken)
{
SessionsResponse response = await SendAsync<SessionsResponse>(HttpMethod.Get, "sessions", null, new Dictionary<string, string?>
{
["user_id"] = userId
}, true, cancellationToken);
return response.Sessions;
}
public Task<SessionControlResultDto> StartSessionAsync(StartSessionRequest request, CancellationToken cancellationToken) =>
SendAsync<SessionControlResultDto>(HttpMethod.Post, "sessions", request, null, true, cancellationToken);
public Task<SessionControlResultDto> AttachSessionAsync(string sessionId, AttachSessionRequest request, CancellationToken cancellationToken) =>
SendAsync<SessionControlResultDto>(HttpMethod.Post, $"sessions/{sessionId}/attach", request, null, true, cancellationToken);
public Task<SessionControlResultDto> DetachSessionAsync(string sessionId, DetachSessionRequest request, CancellationToken cancellationToken) =>
SendAsync<SessionControlResultDto>(HttpMethod.Post, $"sessions/{sessionId}/detach", request, null, true, cancellationToken);
public Task<SessionControlResultDto> TakeoverSessionAsync(string sessionId, TakeoverSessionRequest request, CancellationToken cancellationToken) =>
SendAsync<SessionControlResultDto>(HttpMethod.Post, $"sessions/{sessionId}/takeover", request, null, true, cancellationToken);
public Task TerminateSessionAsync(string sessionId, TerminateSessionRequest request, CancellationToken cancellationToken) =>
SendAsync(HttpMethod.Post, $"sessions/{sessionId}/terminate", request, null, true, cancellationToken);
private Task SendAsync(HttpMethod method, string path, object? body, IDictionary<string, string?>? query, bool authorized, CancellationToken cancellationToken) =>
SendAsync<object?>(method, path, body, query, authorized, cancellationToken);
private async Task<T> SendAsync<T>(
HttpMethod method,
string path,
object? body,
IDictionary<string, string?>? query,
bool authorized,
CancellationToken cancellationToken)
{
HttpResponseMessage response = await SendCoreAsync(method, path, body, query, authorized, cancellationToken);
if (typeof(T) == typeof(object))
{
return default!;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
T? payload = await JsonSerializer.DeserializeAsync<T>(stream, SerializerOptions, cancellationToken);
return payload ?? throw new BackendApiException(response.StatusCode, new LocalizedMessageDto
{
Code = "common.empty_response_payload",
MessageKey = "errors.common.unexpected",
FallbackMessage = "Something went wrong. Please try again."
});
}
private async Task<HttpResponseMessage> SendCoreAsync(
HttpMethod method,
string path,
object? body,
IDictionary<string, string?>? query,
bool authorized,
CancellationToken cancellationToken)
{
HttpResponseMessage response = await SendOnceAsync(method, path, body, query, authorized, cancellationToken);
if (response.StatusCode != HttpStatusCode.Unauthorized || !authorized)
{
return await EnsureSuccessAsync(response, cancellationToken);
}
response.Dispose();
bool refreshed = await _tryRefreshAsync(cancellationToken);
if (!refreshed)
{
throw new BackendApiException(HttpStatusCode.Unauthorized, new LocalizedMessageDto
{
Code = "auth.session_expired",
MessageKey = "errors.auth.session_expired",
FallbackMessage = "Session expired. Please sign in again."
});
}
response = await SendOnceAsync(method, path, body, query, authorized, cancellationToken);
return await EnsureSuccessAsync(response, cancellationToken);
}
private async Task<HttpResponseMessage> SendOnceAsync(
HttpMethod method,
string path,
object? body,
IDictionary<string, string?>? query,
bool authorized,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, BuildUri(path, query));
if (body is not null)
{
string json = JsonSerializer.Serialize(body, SerializerOptions);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
}
if (authorized)
{
string? accessToken = await _accessTokenProvider(cancellationToken);
if (!string.IsNullOrWhiteSpace(accessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
}
}
return await _httpClient.SendAsync(request, cancellationToken);
}
private static async Task<HttpResponseMessage> EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
{
return response;
}
string content = await response.Content.ReadAsStringAsync(cancellationToken);
LocalizedMessageDto error = TryExtractError(content) ?? new LocalizedMessageDto
{
Code = "common.request_failed",
MessageKey = "errors.common.unexpected",
FallbackMessage = response.ReasonPhrase ?? "Request failed."
};
throw new BackendApiException(response.StatusCode, error);
}
private Uri BuildUri(string path, IDictionary<string, string?>? query)
{
var builder = new StringBuilder(_options.ApiBaseUrl.TrimEnd('/')).Append('/').Append(path.TrimStart('/'));
if (query is { Count: > 0 })
{
string separator = "?";
foreach ((string key, string? value) in query.Where(pair => !string.IsNullOrWhiteSpace(pair.Value)))
{
builder.Append(separator)
.Append(Uri.EscapeDataString(key))
.Append('=')
.Append(Uri.EscapeDataString(value!));
separator = "&";
}
}
return new Uri(builder.ToString(), UriKind.Absolute);
}
private static LocalizedMessageDto? TryExtractError(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return null;
}
try
{
using JsonDocument document = JsonDocument.Parse(content);
if (document.RootElement.TryGetProperty("error", out JsonElement errorElement))
{
if (errorElement.ValueKind == JsonValueKind.Object)
{
return JsonSerializer.Deserialize<LocalizedMessageDto>(errorElement.GetRawText(), SerializerOptions);
}
if (errorElement.ValueKind == JsonValueKind.String)
{
return new LocalizedMessageDto
{
Code = "common.legacy_error",
MessageKey = "errors.common.unexpected",
FallbackMessage = errorElement.GetString() ?? "Request failed."
};
}
}
}
catch (JsonException)
{
return new LocalizedMessageDto
{
Code = "common.raw_error",
MessageKey = "errors.common.unexpected",
FallbackMessage = content
};
}
return new LocalizedMessageDto
{
Code = "common.raw_error",
MessageKey = "errors.common.unexpected",
FallbackMessage = content
};
}
}
@@ -0,0 +1,24 @@
using RemoteAccessPlatform.Windows.Contracts;
using System.Net;
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Transport;
public sealed class BackendApiException : Exception, IBackendMessageException
{
public BackendApiException(HttpStatusCode statusCode, LocalizedMessageDto error)
: base(error.FallbackMessage)
{
StatusCode = statusCode;
Error = error;
}
public HttpStatusCode StatusCode { get; }
public LocalizedMessageDto Error { get; }
public string Code => Error.Code;
public string MessageKey => Error.MessageKey;
public string FallbackMessage => Error.FallbackMessage;
public string? TraceId => Error.TraceId;
public bool IsUnauthorized => StatusCode == HttpStatusCode.Unauthorized;
public bool IsForbidden => StatusCode == HttpStatusCode.Forbidden;
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\RemoteAccessPlatform.Windows.Contracts\RemoteAccessPlatform.Windows.Contracts.csproj" />
<ProjectReference Include="..\RemoteAccessPlatform.Windows.Models\RemoteAccessPlatform.Windows.Models.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>