Initial commit: IronServices.Client SDK

Unified client for Iron Services APIs with:
- Session-based authentication
- Automatic token persistence
- Access to IronLicensing, IronNotify, and IronTelemetry APIs
- Generic HTTP methods for custom endpoints
This commit is contained in:
David Friedel 2025-12-25 09:09:12 +00:00
commit d64072a334
11 changed files with 1860 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
bin/
obj/
*.user
*.suo
.vs/
*.DotSettings.user

278
ApiModels.cs Normal file
View File

@ -0,0 +1,278 @@
namespace IronServices.Client;
#region Licensing Models
public class LicensingDashboardStats
{
public int TotalLicenses { get; set; }
public int ActiveLicenses { get; set; }
public int ExpiringThisMonth { get; set; }
public int NewThisMonth { get; set; }
public decimal MonthlyRevenue { get; set; }
public int TotalProducts { get; set; }
}
public class LicenseDto
{
public Guid Id { get; set; }
public string LicenseKey { get; set; } = "";
public string Status { get; set; } = "";
public string? CustomerEmail { get; set; }
public string? CustomerName { get; set; }
public string ProductName { get; set; } = "";
public string? ProductSlug { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime? LastActivatedAt { get; set; }
public int MaxActivations { get; set; }
public int CurrentActivations { get; set; }
public string? SuspensionReason { get; set; }
public string? RevocationReason { get; set; }
}
public class ActivityItem
{
public Guid Id { get; set; }
public string Type { get; set; } = "";
public string Description { get; set; } = "";
public DateTime CreatedAt { get; set; }
public string? EntityType { get; set; }
public Guid? EntityId { get; set; }
public string? UserEmail { get; set; }
public Dictionary<string, object>? Metadata { get; set; }
}
public class TicketDto
{
public Guid Id { get; set; }
public string Subject { get; set; } = "";
public string Status { get; set; } = "";
public string Priority { get; set; } = "";
public string? CustomerEmail { get; set; }
public string? CustomerName { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int MessageCount { get; set; }
}
public class TicketDetailDto : TicketDto
{
public List<TicketMessageDto> Messages { get; set; } = [];
}
public class TicketMessageDto
{
public Guid Id { get; set; }
public string Content { get; set; } = "";
public string SenderType { get; set; } = "";
public string? SenderEmail { get; set; }
public string? SenderName { get; set; }
public DateTime CreatedAt { get; set; }
}
public class UpdateProfileRequest
{
public string? DisplayName { get; set; }
public string? CurrentPassword { get; set; }
public string? NewPassword { get; set; }
}
#endregion
#region Notify Models
public class NotificationDto
{
public Guid Id { get; set; }
public Guid EventId { get; set; }
public string Type { get; set; } = "";
public string Title { get; set; } = "";
public string Message { get; set; } = "";
public string Severity { get; set; } = "Info";
public string? AppSlug { get; set; }
public string? AppName { get; set; }
public string? ActionUrl { get; set; }
public string? EntityId { get; set; }
public bool IsRead { get; set; }
public bool IsAcknowledged { get; set; }
public bool IsResolved { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ReadAt { get; set; }
public DateTime? AcknowledgedAt { get; set; }
public DateTime? ResolvedAt { get; set; }
public string? AcknowledgedBy { get; set; }
public string? ResolvedBy { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
}
public class OnCallStatus
{
public bool IsOnCall { get; set; }
public string? OnCallUserName { get; set; }
public string? OnCallUserEmail { get; set; }
public DateTime? OnCallUntil { get; set; }
public string? NextOnCallUserName { get; set; }
public DateTime? NextShiftStart { get; set; }
}
public class ScheduleSlot
{
public Guid UserId { get; set; }
public string UserName { get; set; } = "";
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public bool IsCurrent { get; set; }
}
public class TeamMember
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public string? PhoneNumber { get; set; }
public string? Role { get; set; }
public Guid? TeamId { get; set; }
public string? TeamName { get; set; }
public bool IsOnCall { get; set; }
public int DeviceCount { get; set; }
public DateTime CreatedAt { get; set; }
// Alias for compatibility
public Guid UserId => Id;
}
public class AppSubscription
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public string Slug { get; set; } = "";
public string? Description { get; set; }
public string? WebhookUrl { get; set; }
public string? WebhookSecret { get; set; }
public bool IsActive { get; set; }
public bool IsMuted { get; set; }
public DateTime? MutedUntil { get; set; }
public int UnreadCount { get; set; }
public int TotalNotifications { get; set; }
public DateTime? LastEventAt { get; set; }
public string SeverityFilter { get; set; } = "All";
public DateTime CreatedAt { get; set; }
// Aliases for compatibility
public string AppSlug => Slug;
public string AppName => Name;
}
public class NotificationPreferences
{
public bool PushEnabled { get; set; } = true;
public bool SoundEnabled { get; set; } = true;
public bool VibrationEnabled { get; set; } = true;
public string MinimumSeverity { get; set; } = "Info";
public TimeSpan? QuietHoursStart { get; set; }
public TimeSpan? QuietHoursEnd { get; set; }
}
#endregion
#region Telemetry Models
public class TelemetryDashboardStats
{
public int TotalErrors { get; set; }
public int OpenIssues { get; set; }
public int ResolvedToday { get; set; }
public double ErrorRate { get; set; }
public int ActiveProjects { get; set; }
public List<ErrorTrend> ErrorTrends { get; set; } = [];
public List<TopIssue> TopIssues { get; set; } = [];
}
public class ErrorTrend
{
public DateTime Date { get; set; }
public int Count { get; set; }
}
public class TopIssue
{
public string Id { get; set; } = "";
public string Title { get; set; } = "";
public int EventCount { get; set; }
public string ProjectName { get; set; } = "";
}
public class IssueDto
{
public Guid Id { get; set; }
public string Title { get; set; } = "";
public string? Culprit { get; set; }
public string Level { get; set; } = "error";
public string Severity { get; set; } = "error";
public string Status { get; set; } = "unresolved";
public int EventCount { get; set; }
public int UserCount { get; set; }
public DateTime FirstSeen { get; set; }
public DateTime LastSeen { get; set; }
public Guid? ProjectId { get; set; }
public string? ProjectName { get; set; }
public string? ProjectSlug { get; set; }
public bool IsIgnored { get; set; }
public Guid? AssignedToUserId { get; set; }
public string? AssignedTo { get; set; }
}
public class SignalDto
{
public Guid Id { get; set; }
public string Type { get; set; } = "";
public string Title { get; set; } = "";
public string? Message { get; set; }
public string Level { get; set; } = "info";
public DateTime CreatedAt { get; set; }
public string? ProjectName { get; set; }
public string? IssueId { get; set; }
public Dictionary<string, object>? Metadata { get; set; }
}
public class TelemetryProjectDto
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public string Slug { get; set; } = "";
public string? Platform { get; set; }
public string Dsn { get; set; } = "";
public bool IsActive { get; set; }
public bool StackTraceGroupingEnabled { get; set; }
public bool JourneyTrackingEnabled { get; set; }
public bool SourceMapEnabled { get; set; }
public int SampleRate { get; set; }
public Guid? ProductId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
public class AlertPreferences
{
public bool PushEnabled { get; set; } = true;
public bool EmailEnabled { get; set; } = true;
public string MinimumLevel { get; set; } = "error";
public int SpikeThreshold { get; set; } = 10;
public bool NotifyOnNewIssue { get; set; } = true;
public bool NotifyOnRegression { get; set; } = true;
public bool NotifyOnSpike { get; set; } = true;
public TimeSpan? QuietHoursStart { get; set; }
public TimeSpan? QuietHoursEnd { get; set; }
}
#endregion
#region Internal Response Types
internal class UnreadCountResponse
{
public int Count { get; set; }
public int UnreadCount { get; set; }
}
#endregion

121
ITokenStorage.cs Normal file
View File

@ -0,0 +1,121 @@
namespace IronServices.Client;
/// <summary>
/// Interface for storing authentication tokens.
/// Implement this for platform-specific secure storage (e.g., MAUI SecureStorage, Windows Credential Manager).
/// </summary>
public interface ITokenStorage
{
/// <summary>
/// Save the session token.
/// </summary>
Task SaveTokenAsync(string token, DateTime? expiresAt);
/// <summary>
/// Get the stored session token.
/// </summary>
Task<(string? Token, DateTime? ExpiresAt)> GetTokenAsync();
/// <summary>
/// Clear the stored token.
/// </summary>
Task ClearTokenAsync();
}
/// <summary>
/// In-memory token storage (not persisted across app restarts).
/// </summary>
public class InMemoryTokenStorage : ITokenStorage
{
private string? _token;
private DateTime? _expiresAt;
public Task SaveTokenAsync(string token, DateTime? expiresAt)
{
_token = token;
_expiresAt = expiresAt;
return Task.CompletedTask;
}
public Task<(string? Token, DateTime? ExpiresAt)> GetTokenAsync()
{
return Task.FromResult((_token, _expiresAt));
}
public Task ClearTokenAsync()
{
_token = null;
_expiresAt = null;
return Task.CompletedTask;
}
}
/// <summary>
/// File-based token storage (for console apps, testing).
/// </summary>
public class FileTokenStorage : ITokenStorage
{
private readonly string _filePath;
public FileTokenStorage(string? filePath = null)
{
_filePath = filePath ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"IronServices",
"session.json");
}
public async Task SaveTokenAsync(string token, DateTime? expiresAt)
{
var directory = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
var data = new { token, expiresAt = expiresAt?.ToString("O") };
var json = System.Text.Json.JsonSerializer.Serialize(data);
await File.WriteAllTextAsync(_filePath, json);
}
public async Task<(string? Token, DateTime? ExpiresAt)> GetTokenAsync()
{
if (!File.Exists(_filePath))
{
return (null, null);
}
try
{
var json = await File.ReadAllTextAsync(_filePath);
var data = System.Text.Json.JsonSerializer.Deserialize<TokenData>(json);
DateTime? expiresAt = null;
if (!string.IsNullOrEmpty(data?.ExpiresAt))
{
expiresAt = DateTime.Parse(data.ExpiresAt, null, System.Globalization.DateTimeStyles.RoundtripKind);
}
return (data?.Token, expiresAt);
}
catch
{
return (null, null);
}
}
public Task ClearTokenAsync()
{
if (File.Exists(_filePath))
{
File.Delete(_filePath);
}
return Task.CompletedTask;
}
private class TokenData
{
public string? Token { get; set; }
public string? ExpiresAt { get; set; }
}
}

21
IronServices.Client.csproj Executable file
View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<RootNamespace>IronServices.Client</RootNamespace>
<AssemblyName>IronServices.Client</AssemblyName>
<Description>Client library for IronServices API (IronLicensing, IronTelemetry and IronNotify)</Description>
<Authors>David H Friedel Jr</Authors>
<Company>MarketAlly</Company>
<PackageId>IronServices.Client</PackageId>
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Net.Http.Json" Version="9.0.0" />
</ItemGroup>
</Project>

411
IronServicesClient.cs Executable file
View File

@ -0,0 +1,411 @@
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";
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 IronServices
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

163
LicensingApi.cs Normal file
View File

@ -0,0 +1,163 @@
namespace IronServices.Client;
/// <summary>
/// API client for IronLicensing operations.
/// Access via IronServicesClient.Licensing
/// </summary>
public class LicensingApi
{
private readonly IronServicesClient _client;
private readonly string _baseUrl;
internal LicensingApi(IronServicesClient client, string baseUrl)
{
_client = client;
_baseUrl = baseUrl;
}
private string Url(string endpoint) => IronServicesClient.BuildUrl(_baseUrl, endpoint);
/// <summary>
/// Get dashboard statistics.
/// </summary>
public async Task<LicensingDashboardStats> GetDashboardStatsAsync(CancellationToken ct = default)
{
return await _client.GetAsync<LicensingDashboardStats>(Url("api/v1/dashboard/stats"), ct)
?? new LicensingDashboardStats();
}
/// <summary>
/// Get recent activity from audit logs.
/// </summary>
public async Task<List<ActivityItem>> GetRecentActivityAsync(int limit = 50, CancellationToken ct = default)
{
return await _client.GetAsync<List<ActivityItem>>(Url($"api/v1/admin/audit?limit={limit}"), ct) ?? [];
}
/// <summary>
/// Get licenses with optional filtering.
/// </summary>
public async Task<List<LicenseDto>> GetLicensesAsync(string? status = null, Guid? productId = null, int page = 1, int limit = 50, CancellationToken ct = default)
{
var query = new List<string> { $"page={page}", $"limit={limit}" };
if (!string.IsNullOrEmpty(status)) query.Add($"status={status}");
if (productId.HasValue) query.Add($"productId={productId}");
var queryString = "?" + string.Join("&", query);
return await _client.GetAsync<List<LicenseDto>>(Url($"api/v1/admin/licenses{queryString}"), ct) ?? [];
}
/// <summary>
/// Get a specific license by ID.
/// </summary>
public async Task<LicenseDto?> GetLicenseAsync(Guid id, CancellationToken ct = default)
{
return await _client.GetAsync<LicenseDto>(Url($"api/v1/admin/licenses/{id}"), ct);
}
/// <summary>
/// Create a new license.
/// </summary>
public async Task<LicenseDto?> CreateLicenseAsync(CreateLicenseRequest request, CancellationToken ct = default)
{
return await _client.PostAsync<CreateLicenseRequest, LicenseDto>(Url("api/v1/admin/licenses"), request, ct);
}
/// <summary>
/// Suspend a license.
/// </summary>
public async Task<LicenseDto?> SuspendLicenseAsync(Guid id, string? reason = null, CancellationToken ct = default)
{
return await _client.PostAsync<object, LicenseDto>(Url($"api/v1/admin/licenses/{id}/suspend"), new { reason }, ct);
}
/// <summary>
/// Revoke a license.
/// </summary>
public async Task<LicenseDto?> RevokeLicenseAsync(Guid id, string? reason = null, CancellationToken ct = default)
{
return await _client.PostAsync<object, LicenseDto>(Url($"api/v1/admin/licenses/{id}/revoke"), new { reason }, ct);
}
/// <summary>
/// Reactivate a suspended license.
/// </summary>
public async Task<LicenseDto?> ReactivateLicenseAsync(Guid id, CancellationToken ct = default)
{
return await _client.PostAsync<object, LicenseDto>(Url($"api/v1/admin/licenses/{id}/reactivate"), new { }, ct);
}
/// <summary>
/// Resend activation email for a license.
/// </summary>
public async Task ResendActivationEmailAsync(Guid id, CancellationToken ct = default)
{
await _client.PostAsync<object, object>(Url($"api/v1/admin/licenses/{id}/resend-activation"), new { }, ct);
}
/// <summary>
/// Update user profile.
/// </summary>
public async Task UpdateProfileAsync(UpdateProfileRequest request, CancellationToken ct = default)
{
await _client.PutAsync<UpdateProfileRequest, object>(Url("api/v1/profile"), request, ct);
}
/// <summary>
/// Get tickets with optional status filter.
/// </summary>
public async Task<List<TicketDto>> GetTicketsAsync(string? status = null, CancellationToken ct = default)
{
var query = !string.IsNullOrEmpty(status) ? $"?status={status}" : "";
return await _client.GetAsync<List<TicketDto>>(Url($"api/v1/tickets{query}"), ct) ?? [];
}
/// <summary>
/// Get a specific ticket with messages.
/// </summary>
public async Task<TicketDetailDto?> GetTicketAsync(Guid id, CancellationToken ct = default)
{
return await _client.GetAsync<TicketDetailDto>(Url($"api/v1/tickets/{id}"), ct);
}
/// <summary>
/// Create a new ticket.
/// </summary>
public async Task<TicketDto?> CreateTicketAsync(string subject, string message, string? category = null, CancellationToken ct = default)
{
return await _client.PostAsync<object, TicketDto>(Url("api/v1/tickets"), new { subject, message, category }, ct);
}
/// <summary>
/// Reply to a ticket.
/// </summary>
public async Task<TicketMessageDto?> ReplyToTicketAsync(Guid ticketId, string content, CancellationToken ct = default)
{
return await _client.PostAsync<object, TicketMessageDto>(Url($"api/v1/tickets/{ticketId}/messages"), new { content }, ct);
}
/// <summary>
/// Close a ticket.
/// </summary>
public async Task<TicketDto?> CloseTicketAsync(Guid id, CancellationToken ct = default)
{
return await _client.PostAsync<object, TicketDto>(Url($"api/v1/tickets/{id}/close"), new { }, ct);
}
/// <summary>
/// Reopen a closed ticket.
/// </summary>
public async Task<TicketDto?> ReopenTicketAsync(Guid id, CancellationToken ct = default)
{
return await _client.PostAsync<object, TicketDto>(Url($"api/v1/tickets/{id}/reopen"), new { }, ct);
}
}
public class CreateLicenseRequest
{
public Guid ProductId { get; set; }
public string? Email { get; set; }
public string? TierId { get; set; }
public int? MaxActivations { get; set; }
public DateTime? ExpiresAt { get; set; }
}

108
Models.cs Normal file
View File

@ -0,0 +1,108 @@
namespace IronServices.Client;
#region API Response DTOs
internal class LoginResponse
{
public string? UserId { get; set; }
public string? Email { get; set; }
public string? DisplayName { get; set; }
public bool EmailVerified { get; set; }
public string? SubscriptionTier { get; set; }
public string? Role { get; set; }
public string? SessionToken { get; set; }
public DateTime? ExpiresAt { get; set; }
}
internal class RegisterResponse
{
public UserInfo? User { get; set; }
public string? ApiKey { get; set; }
}
internal class UserInfo
{
public string? Id { get; set; }
public string? TenantId { get; set; }
public string? Email { get; set; }
public string? DisplayName { get; set; }
public bool EmailVerified { get; set; }
public string? Role { get; set; }
}
internal class VerifyEmailResponse
{
public string? Message { get; set; }
public string? SessionToken { get; set; }
public UserInfo? User { get; set; }
}
internal class ErrorResponse
{
public string? Error { get; set; }
}
#endregion
#region Public Result Types
/// <summary>
/// Result of a login attempt.
/// </summary>
public class LoginResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public string? UserId { get; set; }
public string? Email { get; set; }
public string? DisplayName { get; set; }
public string? Role { get; set; }
public DateTime? ExpiresAt { get; set; }
}
/// <summary>
/// Result of a registration attempt.
/// </summary>
public class RegisterResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public string? UserId { get; set; }
public string? Email { get; set; }
public string? ApiKey { get; set; }
}
/// <summary>
/// Result of email verification.
/// </summary>
public class VerifyEmailResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public string? Message { get; set; }
}
/// <summary>
/// Result of password reset.
/// </summary>
public class ResetPasswordResult
{
public bool Success { get; set; }
public string? Error { get; set; }
}
/// <summary>
/// User profile information.
/// </summary>
public class UserProfile
{
public string? UserId { get; set; }
public string? Email { get; set; }
public string? DisplayName { get; set; }
public bool EmailVerified { get; set; }
public string? Role { get; set; }
public string? SubscriptionPlan { get; set; }
public DateTime? CreatedAt { get; set; }
}
#endregion

279
NotifyApi.cs Normal file
View File

@ -0,0 +1,279 @@
namespace IronServices.Client;
/// <summary>
/// API client for IronNotify operations.
/// Access via IronServicesClient.Notify
/// </summary>
public class NotifyApi
{
private readonly IronServicesClient _client;
private readonly string _baseUrl;
internal NotifyApi(IronServicesClient client, string baseUrl)
{
_client = client;
_baseUrl = baseUrl;
}
private string Url(string endpoint) => IronServicesClient.BuildUrl(_baseUrl, endpoint);
#region Notifications
/// <summary>
/// Get notifications for the current user.
/// </summary>
public async Task<List<NotificationDto>> GetNotificationsAsync(string? appSlug = null, bool unreadOnly = false, int page = 1, int pageSize = 50, CancellationToken ct = default)
{
var query = new List<string> { $"page={page}", $"pageSize={pageSize}" };
if (unreadOnly) query.Add("isRead=false");
if (!string.IsNullOrEmpty(appSlug)) query.Add($"type={appSlug}");
var queryString = "?" + string.Join("&", query);
var result = await _client.GetAsync<NotificationsResponse>(Url($"api/v1/notifications{queryString}"), ct);
return result?.Notifications ?? [];
}
/// <summary>
/// Get unread notification count.
/// </summary>
public async Task<int> GetUnreadCountAsync(CancellationToken ct = default)
{
var result = await _client.GetAsync<UnreadCountResponse>(Url("api/v1/notifications/unread-count"), ct);
return result?.Count ?? result?.UnreadCount ?? 0;
}
/// <summary>
/// Mark a notification as read.
/// </summary>
public async Task MarkAsReadAsync(Guid notificationId, CancellationToken ct = default)
{
await _client.PutAsync<object, object>(Url($"api/v1/notifications/{notificationId}/read"), new { }, ct);
}
/// <summary>
/// Mark all notifications as read.
/// </summary>
public async Task MarkAllAsReadAsync(CancellationToken ct = default)
{
await _client.PutAsync<object, object>(Url("api/v1/notifications/read-all"), new { }, ct);
}
/// <summary>
/// Acknowledge a notification (marks as read).
/// </summary>
public async Task AcknowledgeAsync(Guid notificationId, CancellationToken ct = default)
{
await MarkAsReadAsync(notificationId, ct);
}
/// <summary>
/// Get a specific notification by ID.
/// </summary>
public async Task<NotificationDto?> GetNotificationAsync(Guid notificationId, CancellationToken ct = default)
{
return await _client.GetAsync<NotificationDto>(Url($"api/v1/notifications/{notificationId}"), ct);
}
/// <summary>
/// Resolve a notification.
/// </summary>
public async Task ResolveAsync(Guid notificationId, string? resolution = null, CancellationToken ct = default)
{
await _client.PostAsync<object, object>(Url($"api/v1/notifications/{notificationId}/resolve"), new { resolution }, ct);
}
/// <summary>
/// Get notification preferences.
/// </summary>
public async Task<NotificationPreferences?> GetNotificationPreferencesAsync(CancellationToken ct = default)
{
return await _client.GetAsync<NotificationPreferences>(Url("api/v1/notifications/preferences"), ct);
}
/// <summary>
/// Update notification preferences.
/// </summary>
public async Task UpdateNotificationPreferencesAsync(NotificationPreferences prefs, CancellationToken ct = default)
{
await _client.PutAsync<NotificationPreferences, object>(Url("api/v1/notifications/preferences"), prefs, ct);
}
#endregion
#region Apps
/// <summary>
/// Get all apps for the tenant.
/// </summary>
public async Task<List<AppSubscription>> GetMyAppsAsync(CancellationToken ct = default)
{
return await _client.GetAsync<List<AppSubscription>>(Url("api/v1/apps"), ct) ?? [];
}
/// <summary>
/// Get a specific app by ID.
/// </summary>
public async Task<AppSubscription?> GetAppAsync(Guid appId, CancellationToken ct = default)
{
return await _client.GetAsync<AppSubscription>(Url($"api/v1/apps/{appId}"), ct);
}
/// <summary>
/// Get an app by slug.
/// </summary>
public async Task<AppSubscription?> GetAppBySlugAsync(string slug, CancellationToken ct = default)
{
return await _client.GetAsync<AppSubscription>(Url($"api/v1/apps/by-slug/{slug}"), ct);
}
#endregion
#region Members & On-Call
/// <summary>
/// Get all team members.
/// </summary>
public async Task<List<TeamMember>> GetTeamMembersAsync(CancellationToken ct = default)
{
return await _client.GetAsync<List<TeamMember>>(Url("api/v1/members"), ct) ?? [];
}
/// <summary>
/// Get current on-call member.
/// </summary>
public async Task<TeamMember?> GetOnCallMemberAsync(Guid? teamId = null, CancellationToken ct = default)
{
var query = teamId.HasValue ? $"?teamId={teamId}" : "";
return await _client.GetAsync<TeamMember>(Url($"api/v1/members/on-call{query}"), ct);
}
/// <summary>
/// Get on-call status for the current user.
/// </summary>
public async Task<OnCallStatus?> GetOnCallStatusAsync(CancellationToken ct = default)
{
var member = await GetOnCallMemberAsync(ct: ct);
if (member == null) return new OnCallStatus { IsOnCall = false };
return new OnCallStatus
{
IsOnCall = true,
OnCallUserName = member.Name,
OnCallUserEmail = member.Email
};
}
/// <summary>
/// Set on-call status for a member.
/// </summary>
public async Task<TeamMember?> SetOnCallAsync(Guid memberId, bool isOnCall, CancellationToken ct = default)
{
return await _client.PostAsync<object, TeamMember>(Url($"api/v1/members/{memberId}/on-call"), new { isOnCall }, ct);
}
/// <summary>
/// Take on-call shift for current user.
/// </summary>
public async Task TakeOnCallAsync(CancellationToken ct = default)
{
// This would need the current user's member ID - placeholder for now
await Task.CompletedTask;
}
/// <summary>
/// End on-call shift for current user.
/// </summary>
public async Task EndOnCallAsync(CancellationToken ct = default)
{
// This would need the current user's member ID - placeholder for now
await Task.CompletedTask;
}
/// <summary>
/// Request coverage from another team member.
/// </summary>
public async Task RequestCoverageAsync(Guid memberId, CancellationToken ct = default)
{
// Placeholder - would send notification to member
await Task.CompletedTask;
}
/// <summary>
/// Get today's on-call schedule.
/// </summary>
public async Task<List<ScheduleSlot>> GetScheduleAsync(CancellationToken ct = default)
{
// Placeholder - schedule endpoint not implemented in backend
return await Task.FromResult(new List<ScheduleSlot>());
}
#endregion
#region App Muting
/// <summary>
/// Mute notifications from an app.
/// </summary>
public async Task MuteAppAsync(string appSlug, int? minutes = null, CancellationToken ct = default)
{
// Placeholder - mute endpoint not implemented in backend
await Task.CompletedTask;
}
/// <summary>
/// Unmute notifications from an app.
/// </summary>
public async Task UnmuteAppAsync(string appSlug, CancellationToken ct = default)
{
// Placeholder - unmute endpoint not implemented in backend
await Task.CompletedTask;
}
#endregion
#region Teams
/// <summary>
/// Get all teams.
/// </summary>
public async Task<List<TeamInfo>> GetTeamsAsync(CancellationToken ct = default)
{
return await _client.GetAsync<List<TeamInfo>>(Url("api/v1/teams"), ct) ?? [];
}
/// <summary>
/// Get a specific team.
/// </summary>
public async Task<TeamInfo?> GetTeamAsync(Guid teamId, CancellationToken ct = default)
{
return await _client.GetAsync<TeamInfo>(Url($"api/v1/teams/{teamId}"), ct);
}
/// <summary>
/// Get members of a specific team.
/// </summary>
public async Task<List<TeamMember>> GetTeamMembersAsync(Guid teamId, CancellationToken ct = default)
{
return await _client.GetAsync<List<TeamMember>>(Url($"api/v1/teams/{teamId}/members"), ct) ?? [];
}
#endregion
}
public class NotificationsResponse
{
public List<NotificationDto> Notifications { get; set; } = [];
public int TotalCount { get; set; }
public int UnreadCount { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
}
public class TeamInfo
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public int MemberCount { get; set; }
public DateTime CreatedAt { get; set; }
}

237
README.md Normal file
View File

@ -0,0 +1,237 @@
# IronServices.Client
Unified client for Iron Services APIs with session-based authentication. Access IronLicensing, IronNotify, and IronTelemetry from a single authenticated client.
[![NuGet](https://img.shields.io/nuget/v/IronServices.Client.svg)](https://www.nuget.org/packages/IronServices.Client)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
## Installation
```bash
dotnet add package IronServices.Client
```
## Quick Start
```csharp
using IronServices.Client;
// Create client
var client = new IronServicesClient(new IronServicesClientOptions
{
LicensingUrl = "https://ironlicensing.com",
NotifyUrl = "https://ironnotify.com",
TelemetryUrl = "https://irontelemetry.com"
});
// Login
var loginResult = await client.LoginAsync("user@example.com", "password");
if (loginResult.Success)
{
Console.WriteLine($"Welcome, {loginResult.DisplayName}!");
// Access all Iron Services APIs
var licenses = await client.Licensing.GetLicensesAsync();
var events = await client.Notify.GetEventsAsync();
var errors = await client.Telemetry.GetErrorsAsync();
}
```
## Authentication
### Login
```csharp
var result = await client.LoginAsync("user@example.com", "password");
if (result.Success)
{
Console.WriteLine($"User ID: {result.UserId}");
Console.WriteLine($"Role: {result.Role}");
Console.WriteLine($"Expires: {result.ExpiresAt}");
}
else
{
Console.WriteLine($"Login failed: {result.Error}");
}
```
### Register
```csharp
var result = await client.RegisterAsync(
email: "newuser@example.com",
password: "securepassword",
displayName: "John Doe"
);
if (result.Success)
{
Console.WriteLine($"Registered! API Key: {result.ApiKey}");
}
```
### Email Verification
```csharp
var result = await client.VerifyEmailAsync("user@example.com", "123456");
if (result.Success)
{
Console.WriteLine("Email verified!");
}
```
### Password Reset
```csharp
// Request reset email
await client.RequestPasswordResetAsync("user@example.com");
// Reset with token from email
var result = await client.ResetPasswordAsync(token, "newpassword");
```
### Session Management
```csharp
// Check authentication status
if (client.IsAuthenticated)
{
Console.WriteLine($"Session expires: {client.SessionExpiresAt}");
}
// Get current user profile
var profile = await client.GetProfileAsync();
// Logout
await client.LogoutAsync();
```
## Token Storage
Sessions are persisted automatically. Provide a custom storage for platform-specific secure storage:
```csharp
public class SecureTokenStorage : ITokenStorage
{
public async Task SaveTokenAsync(string token, DateTime? expiresAt)
{
await SecureStorage.SetAsync("session_token", token);
if (expiresAt.HasValue)
await SecureStorage.SetAsync("session_expires", expiresAt.Value.ToString("O"));
}
public async Task<(string? token, DateTime? expiresAt)> GetTokenAsync()
{
var token = await SecureStorage.GetAsync("session_token");
var expiresStr = await SecureStorage.GetAsync("session_expires");
DateTime? expires = expiresStr != null ? DateTime.Parse(expiresStr) : null;
return (token, expires);
}
public async Task ClearTokenAsync()
{
SecureStorage.Remove("session_token");
SecureStorage.Remove("session_expires");
}
}
// Use custom storage
var client = new IronServicesClient(options, new SecureTokenStorage());
```
## Service APIs
### IronLicensing
```csharp
// Get all licenses
var licenses = await client.Licensing.GetLicensesAsync();
// Get license details
var license = await client.Licensing.GetLicenseAsync(licenseId);
// Create license
var newLicense = await client.Licensing.CreateLicenseAsync(new CreateLicenseRequest
{
TierId = tierId,
CustomerEmail = "customer@example.com"
});
```
### IronNotify
```csharp
// Get events
var events = await client.Notify.GetEventsAsync();
// Get apps
var apps = await client.Notify.GetAppsAsync();
```
### IronTelemetry
```csharp
// Get errors
var errors = await client.Telemetry.GetErrorsAsync();
// Get projects
var projects = await client.Telemetry.GetProjectsAsync();
```
## Configuration
```csharp
var client = new IronServicesClient(new IronServicesClientOptions
{
// Service URLs (defaults shown)
LicensingUrl = "https://ironlicensing.com",
NotifyUrl = "https://ironnotify.com",
TelemetryUrl = "https://irontelemetry.com"
});
```
For local development:
```csharp
var client = new IronServicesClient(new IronServicesClientOptions
{
LicensingUrl = "https://localhost:5001",
NotifyUrl = "https://localhost:5003",
TelemetryUrl = "https://localhost:5005"
});
```
## Generic API Methods
Make authenticated requests to any endpoint:
```csharp
// GET
var data = await client.GetAsync<MyResponse>("https://ironlicensing.com/api/v1/custom");
// POST
var result = await client.PostAsync<MyRequest, MyResponse>(
"https://ironlicensing.com/api/v1/custom",
new MyRequest { /* ... */ }
);
// PUT
await client.PutAsync<MyRequest, MyResponse>(url, data);
// DELETE
await client.DeleteAsync(url);
```
## Links
- [IronLicensing](https://www.ironlicensing.com)
- [IronNotify](https://www.ironnotify.com)
- [IronTelemetry](https://www.irontelemetry.com)
- [Documentation](https://www.ironlicensing.com/docs)
## License
MIT License - see [LICENSE](LICENSE) for details.

215
TelemetryApi.cs Normal file
View File

@ -0,0 +1,215 @@
namespace IronServices.Client;
/// <summary>
/// API client for IronTelemetry operations.
/// Access via IronServicesClient.Telemetry
/// </summary>
public class TelemetryApi
{
private readonly IronServicesClient _client;
private readonly string _baseUrl;
internal TelemetryApi(IronServicesClient client, string baseUrl)
{
_client = client;
_baseUrl = baseUrl;
}
private string Url(string endpoint) => IronServicesClient.BuildUrl(_baseUrl, endpoint);
#region Dashboard & Stats
/// <summary>
/// Get issue statistics (serves as dashboard stats).
/// </summary>
public async Task<TelemetryDashboardStats> GetDashboardStatsAsync(Guid? projectId = null, CancellationToken ct = default)
{
var query = projectId.HasValue ? $"?projectId={projectId}" : "";
return await _client.GetAsync<TelemetryDashboardStats>(Url($"api/v1/issues/stats{query}"), ct)
?? new TelemetryDashboardStats();
}
#endregion
#region Issues
/// <summary>
/// Get issues with optional filtering.
/// </summary>
public async Task<List<IssueDto>> GetIssuesAsync(Guid? projectId = null, string? status = null, int page = 1, int pageSize = 20, CancellationToken ct = default)
{
var query = new List<string> { $"page={page}", $"pageSize={pageSize}" };
if (projectId.HasValue) query.Add($"projectId={projectId}");
if (!string.IsNullOrEmpty(status)) query.Add($"status={status}");
var queryString = "?" + string.Join("&", query);
var result = await _client.GetAsync<IssuesListResponse>(Url($"api/v1/issues{queryString}"), ct);
return result?.Items ?? [];
}
/// <summary>
/// Get a specific issue by ID.
/// </summary>
public async Task<IssueDto?> GetIssueAsync(Guid issueId, CancellationToken ct = default)
{
return await _client.GetAsync<IssueDto>(Url($"api/v1/issues/{issueId}"), ct);
}
/// <summary>
/// Acknowledge an issue.
/// </summary>
public async Task<IssueDto?> AcknowledgeIssueAsync(Guid issueId, CancellationToken ct = default)
{
return await _client.PostAsync<object, IssueDto>(Url($"api/v1/issues/{issueId}/acknowledge"), new { }, ct);
}
/// <summary>
/// Resolve an issue.
/// </summary>
public async Task<IssueDto?> ResolveIssueAsync(Guid issueId, CancellationToken ct = default)
{
return await _client.PostAsync<object, IssueDto>(Url($"api/v1/issues/{issueId}/resolve"), new { }, ct);
}
/// <summary>
/// Ignore an issue.
/// </summary>
public async Task<IssueDto?> IgnoreIssueAsync(Guid issueId, CancellationToken ct = default)
{
return await _client.PostAsync<object, IssueDto>(Url($"api/v1/issues/{issueId}/ignore"), new { }, ct);
}
/// <summary>
/// Update an issue's status and other properties.
/// </summary>
public async Task<IssueDto?> UpdateIssueAsync(Guid issueId, UpdateIssueRequest request, CancellationToken ct = default)
{
return await _client.PutAsync<UpdateIssueRequest, IssueDto>(Url($"api/v1/issues/{issueId}"), request, ct);
}
#endregion
#region Signals
/// <summary>
/// Get signals (telemetry notifications).
/// </summary>
public async Task<List<SignalDto>> GetSignalsAsync(Guid? projectId = null, int page = 1, int pageSize = 50, CancellationToken ct = default)
{
var query = new List<string> { $"page={page}", $"pageSize={pageSize}" };
if (projectId.HasValue) query.Add($"projectId={projectId}");
var queryString = "?" + string.Join("&", query);
var result = await _client.GetAsync<SignalListResponse>(Url($"api/v1/signals{queryString}"), ct);
return result?.Signals ?? [];
}
/// <summary>
/// Get signal statistics.
/// </summary>
public async Task<SignalStatsResponse?> GetSignalStatsAsync(Guid? projectId = null, CancellationToken ct = default)
{
var query = projectId.HasValue ? $"?projectId={projectId}" : "";
return await _client.GetAsync<SignalStatsResponse>(Url($"api/v1/signals/stats{query}"), ct);
}
/// <summary>
/// Dismiss a signal.
/// </summary>
public async Task<SignalDto?> DismissSignalAsync(Guid signalId, CancellationToken ct = default)
{
return await _client.PostAsync<object, SignalDto>(Url($"api/v1/signals/{signalId}/dismiss"), new { }, ct);
}
#endregion
#region Projects
/// <summary>
/// Get all projects.
/// </summary>
public async Task<List<TelemetryProjectDto>> GetProjectsAsync(CancellationToken ct = default)
{
return await _client.GetAsync<List<TelemetryProjectDto>>(Url("api/v1/projects"), ct) ?? [];
}
/// <summary>
/// Get a specific project by ID.
/// </summary>
public async Task<TelemetryProjectDto?> GetProjectAsync(Guid projectId, CancellationToken ct = default)
{
return await _client.GetAsync<TelemetryProjectDto>(Url($"api/v1/projects/{projectId}"), ct);
}
/// <summary>
/// Create a new telemetry project.
/// </summary>
public async Task<TelemetryProjectDto?> CreateProjectAsync(string name, string platform = "DotNet", CancellationToken ct = default)
{
return await _client.PostAsync<object, TelemetryProjectDto>(Url("api/v1/projects"), new { name, platform }, ct);
}
/// <summary>
/// Update a project.
/// </summary>
public async Task<TelemetryProjectDto?> UpdateProjectAsync(Guid projectId, UpdateProjectRequest request, CancellationToken ct = default)
{
return await _client.PutAsync<UpdateProjectRequest, TelemetryProjectDto>(Url($"api/v1/projects/{projectId}"), request, ct);
}
/// <summary>
/// Regenerate project DSN.
/// </summary>
public async Task<TelemetryProjectDto?> RegenerateDsnAsync(Guid projectId, CancellationToken ct = default)
{
return await _client.PostAsync<object, TelemetryProjectDto>(Url($"api/v1/projects/{projectId}/regenerate-dsn"), new { }, ct);
}
#endregion
}
public class IssuesListResponse
{
public List<IssueDto> Items { get; set; } = [];
public int Total { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
}
public class SignalListResponse
{
public List<SignalDto> Signals { get; set; } = [];
public int Total { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalPages { get; set; }
}
public class SignalStatsResponse
{
public int TotalSignals { get; set; }
public int SignalsLast24Hours { get; set; }
public int SignalsLast7Days { get; set; }
public int SentToNotify { get; set; }
public int PendingSignals { get; set; }
}
public class UpdateIssueRequest
{
public string? Status { get; set; }
public string? Severity { get; set; }
public Guid? AssignedToUserId { get; set; }
public bool? NotifyOnRegression { get; set; }
public bool? NotifyOnNewOccurrence { get; set; }
}
public class UpdateProjectRequest
{
public string? Name { get; set; }
public string? Platform { get; set; }
public bool? IsActive { get; set; }
public bool? StackTraceGroupingEnabled { get; set; }
public bool? JourneyTrackingEnabled { get; set; }
public bool? SourceMapEnabled { get; set; }
public int? SampleRate { get; set; }
}