412 lines
14 KiB
C#
Executable File
412 lines
14 KiB
C#
Executable File
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace IronServices.Client;
|
|
|
|
/// <summary>
|
|
/// Client for IronServices APIs with session-based authentication.
|
|
/// Handles login, token storage, and automatic bearer token attachment across multiple services.
|
|
/// </summary>
|
|
public class IronServicesClient : IDisposable
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ITokenStorage _tokenStorage;
|
|
private readonly JsonSerializerOptions _jsonOptions;
|
|
private readonly IronServicesClientOptions _options;
|
|
private string? _sessionToken;
|
|
private DateTime? _expiresAt;
|
|
private bool _disposed;
|
|
|
|
// Lazy-initialized API services
|
|
private LicensingApi? _licensing;
|
|
private NotifyApi? _notify;
|
|
private TelemetryApi? _telemetry;
|
|
|
|
/// <summary>
|
|
/// IronLicensing API operations.
|
|
/// </summary>
|
|
public LicensingApi Licensing => _licensing ??= new LicensingApi(this, _options.LicensingUrl);
|
|
|
|
/// <summary>
|
|
/// IronNotify API operations.
|
|
/// </summary>
|
|
public NotifyApi Notify => _notify ??= new NotifyApi(this, _options.NotifyUrl);
|
|
|
|
/// <summary>
|
|
/// IronTelemetry API operations.
|
|
/// </summary>
|
|
public TelemetryApi Telemetry => _telemetry ??= new TelemetryApi(this, _options.TelemetryUrl);
|
|
|
|
/// <summary>
|
|
/// Creates a new IronServices client with multiple service URLs.
|
|
/// </summary>
|
|
public IronServicesClient(IronServicesClientOptions options, ITokenStorage? tokenStorage = null)
|
|
{
|
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
|
|
_httpClient = new HttpClient();
|
|
_httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
|
|
|
|
_tokenStorage = tokenStorage ?? new InMemoryTokenStorage();
|
|
_jsonOptions = new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
Converters = { new JsonStringEnumConverter() }
|
|
};
|
|
|
|
// Try to restore session from storage
|
|
RestoreSession();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new IronServices client with a single base URL (legacy compatibility).
|
|
/// All services will use this URL.
|
|
/// </summary>
|
|
public IronServicesClient(string baseUrl, ITokenStorage? tokenStorage = null)
|
|
: this(new IronServicesClientOptions
|
|
{
|
|
LicensingUrl = baseUrl,
|
|
NotifyUrl = baseUrl,
|
|
TelemetryUrl = baseUrl
|
|
}, tokenStorage)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether the client has a valid session token.
|
|
/// </summary>
|
|
public bool IsAuthenticated => !string.IsNullOrEmpty(_sessionToken) &&
|
|
(_expiresAt == null || _expiresAt > DateTime.UtcNow);
|
|
|
|
/// <summary>
|
|
/// Current session expiration time (UTC).
|
|
/// </summary>
|
|
public DateTime? SessionExpiresAt => _expiresAt;
|
|
|
|
/// <summary>
|
|
/// The current session token (for advanced scenarios).
|
|
/// </summary>
|
|
public string? SessionToken => _sessionToken;
|
|
|
|
/// <summary>
|
|
/// Login with email and password.
|
|
/// </summary>
|
|
public async Task<LoginResult> LoginAsync(string email, string password, CancellationToken cancellationToken = default)
|
|
{
|
|
var request = new { email, password };
|
|
var url = BuildUrl(_options.LicensingUrl, "api/v1/users/login");
|
|
|
|
var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var error = await ReadErrorAsync(response, cancellationToken);
|
|
return new LoginResult { Success = false, Error = error };
|
|
}
|
|
|
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>(_jsonOptions, cancellationToken);
|
|
|
|
if (result == null || string.IsNullOrEmpty(result.SessionToken))
|
|
{
|
|
return new LoginResult { Success = false, Error = "Invalid response from server" };
|
|
}
|
|
|
|
// Store session
|
|
await SetSessionAsync(result.SessionToken, result.ExpiresAt);
|
|
|
|
return new LoginResult
|
|
{
|
|
Success = true,
|
|
UserId = result.UserId,
|
|
Email = result.Email,
|
|
DisplayName = result.DisplayName,
|
|
Role = result.Role,
|
|
ExpiresAt = result.ExpiresAt
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Register a new account.
|
|
/// </summary>
|
|
public async Task<RegisterResult> RegisterAsync(string email, string password, string? displayName = null, CancellationToken cancellationToken = default)
|
|
{
|
|
var request = new { email, password, displayName };
|
|
var url = BuildUrl(_options.LicensingUrl, "api/v1/users/register");
|
|
|
|
var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var error = await ReadErrorAsync(response, cancellationToken);
|
|
return new RegisterResult { Success = false, Error = error };
|
|
}
|
|
|
|
var result = await response.Content.ReadFromJsonAsync<RegisterResponse>(_jsonOptions, cancellationToken);
|
|
|
|
return new RegisterResult
|
|
{
|
|
Success = true,
|
|
UserId = result?.User?.Id,
|
|
Email = result?.User?.Email,
|
|
ApiKey = result?.ApiKey
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Logout and clear session.
|
|
/// </summary>
|
|
public async Task LogoutAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
if (IsAuthenticated)
|
|
{
|
|
try
|
|
{
|
|
var url = BuildUrl(_options.LicensingUrl, "api/v1/users/logout");
|
|
await _httpClient.PostAsync(url, null, cancellationToken);
|
|
}
|
|
catch
|
|
{
|
|
// Ignore logout errors
|
|
}
|
|
}
|
|
|
|
await ClearSessionAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get current user profile.
|
|
/// </summary>
|
|
public async Task<UserProfile?> GetProfileAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
EnsureAuthenticated();
|
|
|
|
var url = BuildUrl(_options.LicensingUrl, "api/v1/users/me");
|
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
|
{
|
|
await ClearSessionAsync();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return await response.Content.ReadFromJsonAsync<UserProfile>(_jsonOptions, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify email with 6-digit code.
|
|
/// </summary>
|
|
public async Task<VerifyEmailResult> VerifyEmailAsync(string email, string code, CancellationToken cancellationToken = default)
|
|
{
|
|
var request = new { email, code };
|
|
var url = BuildUrl(_options.LicensingUrl, "api/v1/users/verify-email");
|
|
|
|
var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var error = await ReadErrorAsync(response, cancellationToken);
|
|
return new VerifyEmailResult { Success = false, Error = error };
|
|
}
|
|
|
|
var result = await response.Content.ReadFromJsonAsync<VerifyEmailResponse>(_jsonOptions, cancellationToken);
|
|
|
|
if (result?.SessionToken != null)
|
|
{
|
|
await SetSessionAsync(result.SessionToken, null);
|
|
}
|
|
|
|
return new VerifyEmailResult
|
|
{
|
|
Success = true,
|
|
Message = result?.Message ?? "Email verified"
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request password reset email.
|
|
/// </summary>
|
|
public async Task<bool> RequestPasswordResetAsync(string email, CancellationToken cancellationToken = default)
|
|
{
|
|
var request = new { email };
|
|
var url = BuildUrl(_options.LicensingUrl, "api/v1/users/forgot-password");
|
|
var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken);
|
|
return response.IsSuccessStatusCode;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reset password with token.
|
|
/// </summary>
|
|
public async Task<ResetPasswordResult> ResetPasswordAsync(string token, string newPassword, CancellationToken cancellationToken = default)
|
|
{
|
|
var request = new { token, newPassword };
|
|
var url = BuildUrl(_options.LicensingUrl, "api/v1/users/reset-password");
|
|
|
|
var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var error = await ReadErrorAsync(response, cancellationToken);
|
|
return new ResetPasswordResult { Success = false, Error = error };
|
|
}
|
|
|
|
return new ResetPasswordResult { Success = true };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Make an authenticated GET request to a specific URL.
|
|
/// </summary>
|
|
public async Task<T?> GetAsync<T>(string fullUrl, CancellationToken cancellationToken = default)
|
|
{
|
|
EnsureAuthenticated();
|
|
var response = await _httpClient.GetAsync(fullUrl, cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
throw new HttpRequestException($"GET {fullUrl} failed with {(int)response.StatusCode} {response.ReasonPhrase}: {errorContent}");
|
|
}
|
|
return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Make an authenticated POST request to a specific URL.
|
|
/// </summary>
|
|
public async Task<TResponse?> PostAsync<TRequest, TResponse>(string fullUrl, TRequest data, CancellationToken cancellationToken = default)
|
|
{
|
|
EnsureAuthenticated();
|
|
var response = await _httpClient.PostAsJsonAsync(fullUrl, data, _jsonOptions, cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
throw new HttpRequestException($"POST {fullUrl} failed with {(int)response.StatusCode} {response.ReasonPhrase}: {errorContent}");
|
|
}
|
|
return await response.Content.ReadFromJsonAsync<TResponse>(_jsonOptions, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Make an authenticated PUT request to a specific URL.
|
|
/// </summary>
|
|
public async Task<TResponse?> PutAsync<TRequest, TResponse>(string fullUrl, TRequest data, CancellationToken cancellationToken = default)
|
|
{
|
|
EnsureAuthenticated();
|
|
var response = await _httpClient.PutAsJsonAsync(fullUrl, data, _jsonOptions, cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
throw new HttpRequestException($"PUT {fullUrl} failed with {(int)response.StatusCode} {response.ReasonPhrase}: {errorContent}");
|
|
}
|
|
return await response.Content.ReadFromJsonAsync<TResponse>(_jsonOptions, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Make an authenticated DELETE request to a specific URL.
|
|
/// </summary>
|
|
public async Task DeleteAsync(string fullUrl, CancellationToken cancellationToken = default)
|
|
{
|
|
EnsureAuthenticated();
|
|
var response = await _httpClient.DeleteAsync(fullUrl, cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
throw new HttpRequestException($"DELETE {fullUrl} failed with {(int)response.StatusCode} {response.ReasonPhrase}: {errorContent}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a full URL from a base URL and endpoint.
|
|
/// </summary>
|
|
internal static string BuildUrl(string baseUrl, string endpoint)
|
|
{
|
|
return baseUrl.TrimEnd('/') + "/" + endpoint.TrimStart('/');
|
|
}
|
|
|
|
private async Task SetSessionAsync(string token, DateTime? expiresAt)
|
|
{
|
|
_sessionToken = token;
|
|
_expiresAt = expiresAt;
|
|
_httpClient.DefaultRequestHeaders.Authorization =
|
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _sessionToken);
|
|
await _tokenStorage.SaveTokenAsync(_sessionToken, _expiresAt);
|
|
}
|
|
|
|
private void RestoreSession()
|
|
{
|
|
var (token, expiresAt) = _tokenStorage.GetTokenAsync().GetAwaiter().GetResult();
|
|
|
|
if (!string.IsNullOrEmpty(token) && (expiresAt == null || expiresAt > DateTime.UtcNow))
|
|
{
|
|
_sessionToken = token;
|
|
_expiresAt = expiresAt;
|
|
_httpClient.DefaultRequestHeaders.Authorization =
|
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _sessionToken);
|
|
}
|
|
}
|
|
|
|
private async Task ClearSessionAsync()
|
|
{
|
|
_sessionToken = null;
|
|
_expiresAt = null;
|
|
_httpClient.DefaultRequestHeaders.Authorization = null;
|
|
await _tokenStorage.ClearTokenAsync();
|
|
}
|
|
|
|
private void EnsureAuthenticated()
|
|
{
|
|
if (!IsAuthenticated)
|
|
{
|
|
throw new InvalidOperationException("Not authenticated. Call LoginAsync first.");
|
|
}
|
|
}
|
|
|
|
private async Task<string> ReadErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
var error = JsonSerializer.Deserialize<ErrorResponse>(content, _jsonOptions);
|
|
return error?.Error ?? $"Request failed with status {response.StatusCode}";
|
|
}
|
|
catch
|
|
{
|
|
return $"Request failed with status {response.StatusCode}";
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (!_disposed)
|
|
{
|
|
_httpClient.Dispose();
|
|
_disposed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configuration options for IronServicesClient.
|
|
/// </summary>
|
|
public class IronServicesClientOptions
|
|
{
|
|
/// <summary>
|
|
/// Base URL for IronLicensing API (also used for auth/user operations).
|
|
/// Default: https://ironlicensing.com
|
|
/// </summary>
|
|
public string LicensingUrl { get; set; } = "https://ironlicensing.com";
|
|
|
|
/// <summary>
|
|
/// Base URL for IronNotify API.
|
|
/// Default: https://ironnotify.com
|
|
/// </summary>
|
|
public string NotifyUrl { get; set; } = "https://ironnotify.com";
|
|
|
|
/// <summary>
|
|
/// Base URL for IronTelemetry API.
|
|
/// Default: https://irontelemetry.com
|
|
/// </summary>
|
|
public string TelemetryUrl { get; set; } = "https://irontelemetry.com";
|
|
}
|