diff --git a/testFrontend/SafeNSound.Sdk/SafeNSound.Sdk.csproj b/testFrontend/SafeNSound.Sdk/SafeNSound.Sdk.csproj
index 1bf7de5..3cf4325 100644
--- a/testFrontend/SafeNSound.Sdk/SafeNSound.Sdk.csproj
+++ b/testFrontend/SafeNSound.Sdk/SafeNSound.Sdk.csproj
@@ -15,4 +15,8 @@
<ProjectReference Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj" />
</ItemGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.4.25258.110" />
+ </ItemGroup>
+
</Project>
diff --git a/testFrontend/SafeNSound.Sdk/SafeNSoundAuthentication.cs b/testFrontend/SafeNSound.Sdk/SafeNSoundAuthentication.cs
index 7d88ec8..429e93c 100644
--- a/testFrontend/SafeNSound.Sdk/SafeNSoundAuthentication.cs
+++ b/testFrontend/SafeNSound.Sdk/SafeNSoundAuthentication.cs
@@ -1,19 +1,35 @@
-namespace SafeNSound.Sdk;
+using System.Text.Json.Serialization;
-public class SafeNSoundAuthentication(SafeNSoundConfiguration config)
-{
- public async Task<SafeNSoundAuthResult> Login(string username, string password)
- {
-
- }
-
- public async Task<SafeNSoundAuthResult> Register(string username, string password)
- {
-
+namespace SafeNSound.Sdk;
+
+public class SafeNSoundAuthentication(SafeNSoundConfiguration config) {
+ // public async Task<SafeNSoundAuthResult> Login(string username, string password)
+ // {
+
+ // }
+
+ public async Task<SafeNSoundAuthResult> Register(RegisterDto registerDto) {
+ var hc = new WrappedHttpClient() {
+ BaseAddress = new Uri(config.BaseUri)
+ };
+
+ var res = await hc.PostAsJsonAsync("/auth/register", registerDto);
+ return null!;
}
}
-public class SafeNSoundAuthResult
-{
-
-}
\ No newline at end of file
+public class RegisterDto {
+ [JsonPropertyName("username")]
+ public string Username { get; set; } = string.Empty;
+
+ [JsonPropertyName("password")]
+ public string Password { get; set; } = string.Empty;
+
+ [JsonPropertyName("email")]
+ public string Email { get; set; } = string.Empty;
+
+ [JsonPropertyName("type")]
+ public string UserType { get; set; } = string.Empty;
+}
+
+public class SafeNSoundAuthResult { }
\ No newline at end of file
diff --git a/testFrontend/SafeNSound.Sdk/SafeNSoundConfiguration.cs b/testFrontend/SafeNSound.Sdk/SafeNSoundConfiguration.cs
index d479825..b2aa2c0 100644
--- a/testFrontend/SafeNSound.Sdk/SafeNSoundConfiguration.cs
+++ b/testFrontend/SafeNSound.Sdk/SafeNSoundConfiguration.cs
@@ -1,6 +1,13 @@
+using Microsoft.Extensions.Configuration;
+
namespace SafeNSound.Sdk;
public class SafeNSoundConfiguration
{
+ public SafeNSoundConfiguration(IConfiguration configuration)
+ {
+ configuration.GetRequiredSection("SafeNSound").Bind(this);
+ }
+ public string BaseUri { get; set; } = "http://localhost:3000";
}
\ No newline at end of file
diff --git a/testFrontend/SafeNSound.Sdk/SafeNSoundException.cs b/testFrontend/SafeNSound.Sdk/SafeNSoundException.cs
new file mode 100644
index 0000000..8915d19
--- /dev/null
+++ b/testFrontend/SafeNSound.Sdk/SafeNSoundException.cs
@@ -0,0 +1,87 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using ArcaneLibs.Extensions;
+
+// ReSharper disable MemberCanBePrivate.Global
+
+namespace SafeNSound.Sdk;
+
+public class SafeNSoundExceptionContent {
+ [JsonPropertyName("errCode")]
+ public required string ErrorCode { get; set; }
+
+ [JsonPropertyName("message")]
+ public required string Error { get; set; }
+
+ [JsonPropertyName("soft_logout")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? SoftLogout { get; set; }
+
+ [JsonPropertyName("retry_after_ms")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public int? RetryAfterMs { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary<string, object> AdditionalData { get; set; } = new();
+}
+
+public class SafeNSoundException : Exception {
+ [JsonPropertyName("errCode")]
+ public required string ErrorCode { get; set; }
+
+ [JsonPropertyName("message")]
+ public required string Error { get; set; }
+
+ [JsonPropertyName("soft_logout")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? SoftLogout { get; set; }
+
+ [JsonPropertyName("retry_after_ms")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public int? RetryAfterMs { get; set; }
+
+ public string RawContent { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary<string, object> AdditionalData { get; set; } = new();
+
+ public object GetAsObject() => new SafeNSoundExceptionContent()
+ { ErrorCode = ErrorCode, Error = Error, SoftLogout = SoftLogout, RetryAfterMs = RetryAfterMs, AdditionalData = AdditionalData };
+
+ public string GetAsJson() => GetAsObject().ToJson(ignoreNull: true);
+
+ public override string Message =>
+ $"{ErrorCode}: " +
+ (!string.IsNullOrWhiteSpace(Error)
+ ? Error
+ : ErrorCode switch {
+ _ => $"Unknown error: {GetAsJson()}"
+ });
+
+ [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Follows spec naming")]
+ public static class ErrorCodes {
+ public const string INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR";
+ }
+}
+
+public class SafeNSoundClientException : Exception {
+ [JsonPropertyName("errcode")]
+ public required string ErrorCode { get; set; }
+
+ [JsonPropertyName("error")]
+ public required string Error { get; set; }
+
+ public object GetAsObject() => new { errcode = ErrorCode, error = Error };
+ public string GetAsJson() => GetAsObject().ToJson(ignoreNull: true);
+
+ public override string Message =>
+ $"{ErrorCode}: {ErrorCode switch {
+ "M_UNSUPPORTED" => "The requested feature is not supported",
+ _ => $"Unknown error: {GetAsObject().ToJson(ignoreNull: true)}"
+ }}\nError: {Error}";
+
+ [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Follows spec naming")]
+ public static class ErrorCodes {
+ }
+}
\ No newline at end of file
diff --git a/testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs b/testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs
new file mode 100644
index 0000000..2398a0b
--- /dev/null
+++ b/testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs
@@ -0,0 +1,341 @@
+// based on https://cgit.rory.gay/matrix/LibMatrix.git/plain/LibMatrix/Extensions/MatrixHttpClient.Single.cs
+// #define SYNC_HTTPCLIENT // Only allow one request as a time, for debugging
+
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using ArcaneLibs;
+using ArcaneLibs.Extensions;
+
+
+namespace SafeNSound.Sdk;
+
+// TODO: Add URI wrapper for
+public class WrappedHttpClient
+{
+ private static readonly HttpClient Client;
+
+ static WrappedHttpClient()
+ {
+ try
+ {
+ var handler = new SocketsHttpHandler
+ {
+ PooledConnectionLifetime = TimeSpan.FromMinutes(15),
+ MaxConnectionsPerServer = 4096,
+ EnableMultipleHttp2Connections = true
+ };
+ Client = new HttpClient(handler)
+ {
+ DefaultRequestVersion = new Version(3, 0),
+ Timeout = TimeSpan.FromDays(1)
+ };
+ }
+ catch (PlatformNotSupportedException e)
+ {
+ Console.WriteLine("Failed to create HttpClient with connection pooling, continuing without connection pool!");
+ Console.WriteLine("Original exception (safe to ignore!):");
+ Console.WriteLine(e);
+
+ Client = new HttpClient
+ {
+ DefaultRequestVersion = new Version(3, 0)
+ };
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine("Failed to create HttpClient:");
+ Console.WriteLine(e);
+ throw;
+ }
+ }
+
+#if SYNC_HTTPCLIENT
+ internal SemaphoreSlim _rateLimitSemaphore { get; } = new(1, 1);
+#endif
+
+ public static bool LogRequests = true;
+ public Dictionary<string, string> AdditionalQueryParameters { get; set; } = new();
+
+ public Uri? BaseAddress { get; set; }
+
+ // default headers, not bound to client
+ public HttpRequestHeaders DefaultRequestHeaders { get; set; } =
+ typeof(HttpRequestHeaders).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, [], null)?.Invoke([]) as HttpRequestHeaders ??
+ throw new InvalidOperationException("Failed to create HttpRequestHeaders");
+
+ private static JsonSerializerOptions GetJsonSerializerOptions(JsonSerializerOptions? options = null)
+ {
+ options ??= new JsonSerializerOptions();
+ // options.Converters.Add(new JsonFloatStringConverter());
+ // options.Converters.Add(new JsonDoubleStringConverter());
+ // options.Converters.Add(new JsonDecimalStringConverter());
+ options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
+ return options;
+ }
+
+ public async Task<HttpResponseMessage> SendUnhandledAsync(HttpRequestMessage request, CancellationToken cancellationToken, int attempt = 0)
+ {
+ if (request.RequestUri is null) throw new NullReferenceException("RequestUri is null");
+ // if (!request.RequestUri.IsAbsoluteUri)
+ request.RequestUri = request.RequestUri.EnsureAbsolute(BaseAddress!);
+ var swWait = Stopwatch.StartNew();
+#if SYNC_HTTPCLIENT
+ await _rateLimitSemaphore.WaitAsync(cancellationToken);
+#endif
+
+ if (request.RequestUri is null) throw new NullReferenceException("RequestUri is null");
+ if (!request.RequestUri.IsAbsoluteUri)
+ request.RequestUri = new Uri(BaseAddress ?? throw new InvalidOperationException("Relative URI passed, but no BaseAddress is specified!"), request.RequestUri);
+ swWait.Stop();
+ var swExec = Stopwatch.StartNew();
+
+ foreach (var (key, value) in AdditionalQueryParameters) request.RequestUri = request.RequestUri.AddQuery(key, value);
+ foreach (var (key, value) in DefaultRequestHeaders)
+ {
+ if (request.Headers.Contains(key)) continue;
+ request.Headers.Add(key, value);
+ }
+
+ request.Options.Set(new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse"), true);
+
+ if (LogRequests)
+ Console.WriteLine("Sending " + request.Summarise(includeHeaders: true, includeQuery: true, includeContentIfText: false, hideHeaders: ["Accept"]));
+
+ HttpResponseMessage? responseMessage;
+ try
+ {
+ responseMessage = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+ }
+ catch (Exception e)
+ {
+ if (attempt >= 5)
+ {
+ Console.WriteLine(
+ $"Failed to send request {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}):\n{e}");
+ throw;
+ }
+
+ if (e is TaskCanceledException or TimeoutException or HttpRequestException)
+ {
+ if (request.Method == HttpMethod.Get && !cancellationToken.IsCancellationRequested)
+ {
+ await Task.Delay(Random.Shared.Next(500, 2500), cancellationToken);
+ request.ResetSendStatus();
+ return await SendUnhandledAsync(request, cancellationToken, attempt + 1);
+ }
+ }
+ else if (!e.ToString().StartsWith("TypeError: NetworkError"))
+ Console.WriteLine(
+ $"Failed to send request {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}):\n{e}");
+
+ throw;
+ }
+#if SYNC_HTTPCLIENT
+ finally {
+ _rateLimitSemaphore.Release();
+ }
+#endif
+
+ // Console.WriteLine($"Sending {request.Method} {request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}) -> {(int)responseMessage.StatusCode} {responseMessage.StatusCode} ({Util.BytesToString(responseMessage.GetContentLength())}, WAIT={swWait.ElapsedMilliseconds}ms, EXEC={swExec.ElapsedMilliseconds}ms)");
+ if (LogRequests)
+ Console.WriteLine("Received " + responseMessage.Summarise(includeHeaders: true, includeContentIfText: false, hideHeaders:
+ [
+ "Server",
+ "Date",
+ "Transfer-Encoding",
+ "Connection",
+ "Vary",
+ "Content-Length",
+ "Access-Control-Allow-Origin",
+ "Access-Control-Allow-Methods",
+ "Access-Control-Allow-Headers",
+ "Access-Control-Expose-Headers",
+ "Cache-Control",
+ "Cross-Origin-Resource-Policy",
+ "X-Content-Security-Policy",
+ "Referrer-Policy",
+ "X-Robots-Tag",
+ "Content-Security-Policy"
+ ]));
+
+ return responseMessage;
+ }
+
+ public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default)
+ {
+ var responseMessage = await SendUnhandledAsync(request, cancellationToken);
+ if (responseMessage.IsSuccessStatusCode) return responseMessage;
+
+ //retry on gateway timeout
+ // if (responseMessage.StatusCode == HttpStatusCode.GatewayTimeout) {
+ // request.ResetSendStatus();
+ // return await SendAsync(request, cancellationToken);
+ // }
+
+ //error handling
+ var content = await responseMessage.Content.ReadAsStringAsync(cancellationToken);
+ if (content.Length == 0)
+ throw new SafeNSoundClientException()
+ {
+ ErrorCode = "M_UNKNOWN",
+ Error = "Unknown error, server returned no content"
+ };
+
+ // if (!content.StartsWith('{')) throw new InvalidDataException("Encountered invalid data:\n" + content);
+ if (!content.TrimStart().StartsWith('{'))
+ {
+ responseMessage.EnsureSuccessStatusCode();
+ throw new InvalidDataException("Encountered invalid data:\n" + content);
+ }
+ //we have a matrix error
+
+ SafeNSoundException? ex;
+ try
+ {
+ ex = JsonSerializer.Deserialize<SafeNSoundException>(content);
+ }
+ catch (JsonException e)
+ {
+ throw new SafeNSoundClientException()
+ {
+ ErrorCode = "INVALID_JSON",
+ Error = e.Message + "\nBody:\n" + await responseMessage.Content.ReadAsStringAsync(cancellationToken)
+ };
+ }
+
+ Debug.Assert(ex != null, nameof(ex) + " != null");
+ ex.RawContent = content;
+ // Console.WriteLine($"Failed to send request: {ex}");
+ if (ex.RetryAfterMs is null) throw ex!;
+ //we have a ratelimit error
+ await Task.Delay(ex.RetryAfterMs.Value, cancellationToken);
+ request.ResetSendStatus();
+ return await SendAsync(request, cancellationToken);
+ }
+
+ // GetAsync
+ public Task<HttpResponseMessage> GetAsync([StringSyntax("Uri")] string? requestUri, CancellationToken? cancellationToken = null) =>
+ SendAsync(new HttpRequestMessage(HttpMethod.Get, requestUri), cancellationToken ?? CancellationToken.None);
+
+ // GetFromJsonAsync
+ public async Task<T?> TryGetFromJsonAsync<T>(string requestUri, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ return await GetFromJsonAsync<T>(requestUri, options, cancellationToken);
+ }
+ catch (JsonException e)
+ {
+ Console.WriteLine($"Failed to deserialize response from {requestUri}: {e.Message}");
+ return default;
+ }
+ catch (HttpRequestException e)
+ {
+ Console.WriteLine($"Failed to get {requestUri}: {e.Message}");
+ return default;
+ }
+ }
+
+ public async Task<T> GetFromJsonAsync<T>(string requestUri, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ options = GetJsonSerializerOptions(options);
+ var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ var response = await SendAsync(request, cancellationToken);
+ response.EnsureSuccessStatusCode();
+ await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
+
+ return await JsonSerializer.DeserializeAsync<T>(responseStream, options, cancellationToken) ??
+ throw new InvalidOperationException("Failed to deserialize response");
+ }
+
+ // GetStreamAsync
+ public async Task<Stream> GetStreamAsync(string requestUri, CancellationToken cancellationToken = default)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ var response = await SendAsync(request, cancellationToken);
+ response.EnsureSuccessStatusCode();
+ return await response.Content.ReadAsStreamAsync(cancellationToken);
+ }
+
+ public async Task<HttpResponseMessage> PutAsJsonAsync<T>([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T value, JsonSerializerOptions? options = null,
+ CancellationToken cancellationToken = default) where T : notnull
+ {
+ options = GetJsonSerializerOptions(options);
+ var request = new HttpRequestMessage(HttpMethod.Put, requestUri);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ request.Content = new StringContent(JsonSerializer.Serialize(value, value.GetType(), options),
+ Encoding.UTF8, "application/json");
+ return await SendAsync(request, cancellationToken);
+ }
+
+ public async Task<HttpResponseMessage> PostAsJsonAsync<T>([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T value, JsonSerializerOptions? options = null,
+ CancellationToken cancellationToken = default) where T : notnull
+ {
+ options ??= new JsonSerializerOptions();
+ // options.Converters.Add(new JsonFloatStringConverter());
+ // options.Converters.Add(new JsonDoubleStringConverter());
+ // options.Converters.Add(new JsonDecimalStringConverter());
+ options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
+ var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ request.Content = new StringContent(JsonSerializer.Serialize(value, value.GetType(), options),
+ Encoding.UTF8, "application/json");
+ return await SendAsync(request, cancellationToken);
+ }
+
+ public async IAsyncEnumerable<T?> GetAsyncEnumerableFromJsonAsync<T>([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, JsonSerializerOptions? options = null)
+ {
+ options = GetJsonSerializerOptions(options);
+ var res = await GetAsync(requestUri);
+ var result = JsonSerializer.DeserializeAsyncEnumerable<T>(await res.Content.ReadAsStreamAsync(), options);
+ await foreach (var resp in result) yield return resp;
+ }
+
+ public static async Task<bool> CheckSuccessStatus(string url)
+ {
+ //cors causes failure, try to catch
+ try
+ {
+ var resp = await Client.GetAsync(url);
+ return resp.IsSuccessStatusCode;
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine($"Failed to check success status: {e.Message}");
+ return false;
+ }
+ }
+
+ public async Task<HttpResponseMessage> PostAsync(string uri, HttpContent? content, CancellationToken cancellationToken = default)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, uri)
+ {
+ Content = content
+ };
+ return await SendAsync(request, cancellationToken);
+ }
+
+ public async Task DeleteAsync(string url)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Delete, url);
+ await SendAsync(request);
+ }
+
+ public async Task<HttpResponseMessage> DeleteAsJsonAsync<T>(string url, T payload)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Delete, url)
+ {
+ Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
+ };
+ return await SendAsync(request);
+ }
+}
\ No newline at end of file
|