ironservices-dotnet/IronServicesClient.cs

412 lines
14 KiB
C#

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";
}