diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..f249e8f
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,17 @@
+# Updown.io .NET Client - Environment Variables Example
+# Copy this file to .env and fill in your actual values
+# NEVER commit the .env file to version control
+
+# Your Updown.io API key (get it from https://updown.io/settings/edit)
+# Used for manual integration tests
+UPDOWN_API_KEY=your-api-key-here
+
+# Your pulse monitoring URL (from Updown.io dashboard)
+# Format: https://pulse.updown.io/YOUR-TOKEN/YOUR-KEY
+UPDOWN_PULSE_URL=https://pulse.updown.io/your-token/your-key
+
+# Notes:
+# - These values are only needed if you want to run manual tests against the real API
+# - The automated unit tests do NOT require these values
+# - For security, use separate API keys for development/testing vs production
+# - This file (.env) is already in .gitignore and will not be committed
diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index 7f4abcc..6b9e057 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -24,6 +24,10 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
+ - name: Setup .NET 9
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
diff --git a/.github/workflows/build-test-nuget.yml b/.github/workflows/build-test-nuget.yml
index 81251ad..624b059 100644
--- a/.github/workflows/build-test-nuget.yml
+++ b/.github/workflows/build-test-nuget.yml
@@ -6,7 +6,6 @@ name: build, test, push nuget
on:
workflow_dispatch:
release:
- branches: [ "main" ]
types: [released]
jobs:
@@ -16,10 +15,18 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Setup .NET
+ - name: Setup .NET 6
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 6.0.x
+ - name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
+ - name: Setup .NET 9
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
diff --git a/.gitignore b/.gitignore
index 8a30d25..f3df866 100644
--- a/.gitignore
+++ b/.gitignore
@@ -396,3 +396,18 @@ FodyWeavers.xsd
# JetBrains Rider
*.sln.iml
+
+# Secrets and API Keys
+*.env
+*.env.local
+*secrets.json
+*secrets*.json
+appsettings.Development.json
+appsettings.Local.json
+TestConfiguration.json
+TestSettings.json
+
+# Manual test configuration (if using external config)
+test/**/*Configuration.json
+test/**/*Settings.json
+!test/**/*Settings.Example.json
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..0a8041b
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,353 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [2.0.0-rc.1] - 2025-10-21
+
+**Release Candidate** - This pre-release version is feature-complete and ready for testing. Please report any issues before the final 2.0.0 release.
+
+This major release represents a comprehensive modernization of the Updown.io .NET Client with significant improvements to code quality, API design, documentation, and testing. While breaking changes are minimal due to backward compatibility measures, this is marked as a major version to signal the substantial enhancements and the deprecation of older patterns.
+
+### Added
+
+#### .NET 9 Support
+- Added `net9.0` as a target framework
+- Library now supports .NET 9, .NET 8, .NET 6, and .NET Standard 2.0
+- All 49 tests pass on all target frameworks
+
+#### New API Endpoints
+- **Downtimes API** (`ApiDowntimes.cs`)
+ - `DowntimesAsync(string token, int? page, CancellationToken)` - Retrieve downtime history for checks
+ - `Downtimes(string token, int? page)` - Synchronous wrapper (marked obsolete)
+ - New `Downtime` model with proper nullable annotations
+
+- **Metrics API** (`ApiMetrics.cs`)
+ - `MetricsAsync(string token, string? from, string? to, string? group, CancellationToken)` - Retrieve performance metrics
+ - `Metrics(string token, string? from, string? to, string? group)` - Synchronous wrapper (marked obsolete)
+ - New `Metric` and `RequestMetric` models
+
+- **Nodes API** (`ApiNodes.cs`)
+ - `NodesAsync(CancellationToken)` - List all monitoring nodes
+ - `NodesIpv6Async(CancellationToken)` - List IPv6 addresses of monitoring nodes
+ - `Nodes()` and `NodesIpv6()` - Synchronous wrappers (marked obsolete)
+ - New `Node` model
+
+#### API Design Improvements
+- **Async Method Naming Convention**
+ - All async methods now have `Async` suffix: `ChecksAsync()`, `CheckAsync()`, `CheckCreateAsync()`, etc.
+ - Applied to all APIs: Checks, Recipients, StatusPages, Pulse, Downtimes, Metrics, Nodes
+
+- **CancellationToken Support**
+ - All async methods now accept optional `CancellationToken` parameter
+ - Enables proper cancellation of long-running operations
+ - Improves integration with modern .NET async patterns
+
+- **UpdownClientBuilder** - New builder pattern for client configuration
+ - `WithApiKey(string)` - Set API key
+ - `WithBaseAddress(Uri)` - Configure custom base URL
+ - `WithHttpMessageHandler(HttpMessageHandler)` - Set custom HTTP handler
+ - `WithTimeout(TimeSpan)` - Configure request timeout
+ - `WithUserAgent(string)` - Set custom User-Agent
+ - `Build()` - Create configured `UpdownClient` instance
+ - Thread-safe alternative to static `UpdownClientFactory.Create()`
+
+#### Custom Exception Types
+- `UpdownApiException` - Base exception for all API errors with `StatusCode` and `ResponseContent` properties
+- `UpdownNotFoundException` - Thrown for HTTP 404 Not Found responses
+- `UpdownUnauthorizedException` - Thrown for HTTP 401 Unauthorized responses
+- `UpdownBadRequestException` - Thrown for HTTP 400 Bad Request responses
+- `UpdownRateLimitException` - Thrown for HTTP 429 Too Many Requests with `RetryAfterSeconds` property
+- Enhanced error handling in `UpdownClientBase` with specific exception types for different HTTP status codes
+
+#### Documentation
+- **XML Documentation Comments** - Complete IntelliSense support for all public APIs
+ - Comprehensive parameter descriptions
+ - Return value documentation
+ - Exception documentation
+ - Usage examples where appropriate
+
+- **Architecture Documentation** (`docs/ARCHITECTURE.md`)
+ - System overview and design philosophy
+ - HttpClient lifecycle management
+ - Error handling strategy
+ - Threading and async patterns
+ - Extension points for customization
+
+- **API Reference Documentation** (`docs/API_REFERENCE.md`)
+ - Complete API method reference
+ - Model documentation
+ - Authentication details
+ - Rate limiting considerations
+ - Code examples for all endpoints
+
+- **Contributing Guide** (`docs/CONTRIBUTING.md`)
+ - Development setup instructions
+ - Testing guidelines
+ - Code style conventions
+ - Pull request process
+ - Issue reporting guidelines
+
+- **Enhanced README** (merged from `README_ENHANCED_EXAMPLES.md`)
+ - Real-world usage scenarios
+ - Error handling patterns
+ - Dependency injection examples
+ - ASP.NET Core integration
+ - Best practices
+ - Migration guide for deprecated methods
+ - Troubleshooting section
+
+#### Security Enhancements
+- **Enhanced `.gitignore`** - Added comprehensive rules to prevent API key leaks
+ - Protection for `*.env`, `*.env.local`, `*secrets.json` files
+ - Test configuration files exclusion
+ - Development/local settings protection
+
+- **Security Documentation** (`SECURITY.md`)
+ - Updated with comprehensive API key security best practices
+ - Secure code examples (environment variables, User Secrets, Key Vault)
+ - Vulnerability reporting process
+ - Supported versions clearly stated
+ - DO/DON'T guidelines for contributors and users
+
+- **Environment Variable Template** (`.env.example`)
+ - Safe template for local development configuration
+ - Clear warnings about not committing secrets
+
+- **Test Documentation** (`test/UpdownDotNetTests/README.md`)
+ - Comprehensive testing guide
+ - Security best practices for manual testing
+ - Multiple options for secure API key configuration
+ - Pre-commit security checks
+
+#### Testing Improvements
+- **Error Scenario Tests** (`ErrorHandling/ErrorScenarioTests.cs`)
+ - Rate limit handling tests
+ - Unauthorized access tests
+ - Bad request handling tests
+ - Server error handling tests
+ - Cancellation token tests
+
+- **Comprehensive API Tests**
+ - Full test coverage for Downtimes API
+ - Full test coverage for Metrics API
+ - Full test coverage for Nodes API
+ - Updated all existing tests to use custom exception types
+ - Improved test assertions and error handling
+
+#### Model Enhancements
+- **PascalCase Properties** - All models now use C# naming conventions
+ - `Check`: `LastStatus`, `LastCheckAt`, `NextCheckAt`, `CreatedAt`, `MuteUntil`, `FavIconUrl`, `CustomHeaders`, `HttpVerb`, `HttpBody`, `StringMatch`, `SslCertificate`, `DisabledLocations`, `LastDuration`, `ApdexT`, `HttpBodyMatch`
+ - `Recipient`: `SelectedMonitors`, `PhoneNumber`, `SplitwiseContactId`, `MsTeamsHook`
+ - `StatusPage`: `FavIconUrl`, `CustomCss`, `CustomJavascript`
+ - Old snake_case properties retained with `[Obsolete]` attribute for backward compatibility
+
+- **Nullable Reference Types** - All models properly annotated
+ - Clear indication of which properties can be null
+ - Compile-time null safety checks
+ - Better IDE support and code analysis
+
+- **Common Response Models** (`Models/Responses.cs`)
+ - `DeleteResponse` - Standardized delete operation response
+
+### Changed
+
+#### Breaking Changes (Mitigated with Deprecation)
+- **Language Version** - Updated to C# 9.0 (`9.0`) to support nullable reference types across all target frameworks
+- **Nullable Reference Types** - Enabled across entire codebase (`enable`)
+- **Package Version** - Updated from 1.1.0 to 2.0.0
+- **Method Naming** - All async methods renamed with `Async` suffix (old methods marked obsolete)
+- **Property Naming** - Model properties renamed from snake_case to PascalCase (old properties marked obsolete)
+
+#### Improvements
+- **HttpClient Configuration**
+ - Improved `UpdownClientFactory` thread-safety
+ - Added `PropertyNameCaseInsensitive = true` to JSON serialization options for better flexibility
+ - Better connection pooling with `SocketsHttpHandler` (NET5.0+)
+ - Automatic decompression support (GZip, Deflate)
+
+- **Error Handling**
+ - Enhanced error messages with context
+ - Specific exceptions for different error types
+ - Rate limit information included in exceptions
+ - Better handling of null responses
+
+- **Code Quality**
+ - Consistent use of `ConfigureAwait(false)` for library code
+ - Proper parameter validation with meaningful error messages
+ - Better separation of concerns with partial classes
+ - Improved null handling throughout
+
+### Deprecated
+
+- **UpdownClientFactory Methods**
+ - `Create(string apiKey)` - Use `UpdownClientBuilder` instead for better thread-safety
+ - `Create(HttpClient httpClient)` - Use `UpdownClientBuilder` instead for more flexible configuration
+
+- **API Methods (All deprecated in favor of Async versions)**
+ - **Checks API**: `Checks()`, `Check()`, `CheckCreate()`, `CheckUpdate()`, `CheckDelete()`
+ - **Recipients API**: `Recipients()`, `Recipient()`, `RecipientCreate()`, `RecipientUpdate()`, `RecipientDelete()`
+ - **StatusPages API**: `StatusPages()`, `StatusPage()`, `StatusPageCreate()`, `StatusPageUpdate()`, `StatusPageDelete()`
+ - **Pulse API**: `SendPulse()`
+ - **Downtimes API**: `Downtimes()`
+ - **Metrics API**: `Metrics()`
+ - **Nodes API**: `Nodes()`, `NodesIpv6()`
+
+- **Model Properties (All deprecated in favor of PascalCase versions)**
+ - `Check`: `Last_Status`, `Last_Check_At`, `Next_Check_At`, `Created_At`, `Mute_Until`, `Favicon_Url`, `Custom_Headers`, `Http_Verb`, `Http_Body`, `String_Match`, `Ssl`, `Disabled_Locations`, `Last_Duration`, `Apdex_T`, `Down_Since`
+ - `Recipient`: `Selected_Monitors`, `Phone_Number`, `Splitwise_Contact_Id`, `Msteams_Hook`
+ - `StatusPage`: `Favicon_Url`, `Custom_Css`, `Custom_Javascript`
+ - `Downtime`: `Started_At`, `Ended_At`
+ - `Metric`: `Apdex_T`, `Error_Rate`, `Response_Time`
+
+### Fixed
+
+- **netstandard2.0 Compatibility**
+ - Replaced `HttpStatusCode.TooManyRequests` with `(HttpStatusCode)429` for netstandard2.0 compatibility
+ - Added explicit C# language version to support nullable reference types in netstandard2.0
+
+- **Test Reliability**
+ - Fixed test failures related to null reference handling
+ - Updated test assertions to use new custom exception types
+ - Improved mock data to correctly serialize/deserialize all properties
+ - Added null checks in tests to prevent false positives
+
+- **Serialization Issues**
+ - Fixed JSON property name mapping with `[JsonPropertyName]` attributes
+ - Ensured proper deserialization of all model properties
+ - Added case-insensitive property matching for robustness
+
+### Security
+
+- Enhanced error handling to avoid leaking sensitive information
+- Improved validation of user inputs to prevent injection attacks
+- Better handling of API authentication errors
+- **Manual Test Security**: All manual test files now read API keys from environment variables (`UPDOWN_API_KEY`, `UPDOWN_PULSE_URL`) instead of requiring hardcoded values, reducing risk of accidental credential commits
+
+### Performance
+
+- Optimized HttpClient usage with connection pooling
+- Reduced memory allocations with better async patterns
+- Improved JSON serialization performance with System.Text.Json
+
+---
+
+## [1.1.0] - Previous Release
+
+### Features
+- Initial implementation of Checks API
+- Initial implementation of Recipients API
+- Initial implementation of StatusPages API
+- Initial implementation of Pulse Monitoring
+- Basic error handling with HttpClient
+
+### Target Frameworks
+- .NET 8.0
+- .NET 6.0
+- .NET Standard 2.0
+
+---
+
+## Migration Guide
+
+### Migrating from 1.x to 2.0
+
+#### Using the New Builder Pattern (Recommended)
+```csharp
+// Old way (still works but deprecated)
+var client = UpdownClientFactory.Create("your-api-key");
+
+// New way (recommended)
+var client = new UpdownClientBuilder()
+ .WithApiKey("your-api-key")
+ .WithTimeout(TimeSpan.FromSeconds(30))
+ .Build();
+```
+
+#### Using Async Methods
+```csharp
+// Old way (still works but deprecated)
+var checks = client.Checks();
+
+// New way (recommended)
+var checks = await client.ChecksAsync();
+
+// With cancellation token
+var cts = new CancellationTokenSource();
+var checks = await client.ChecksAsync(cts.Token);
+```
+
+#### Using PascalCase Properties
+```csharp
+// Old way (still works but deprecated)
+var lastStatus = check.Last_Status;
+
+// New way (recommended)
+var lastStatus = check.LastStatus;
+```
+
+#### Handling Specific Exceptions
+```csharp
+// Old way
+try
+{
+ var check = await client.CheckAsync("token");
+}
+catch (HttpRequestException ex)
+{
+ // Generic error handling
+}
+
+// New way
+try
+{
+ var check = await client.CheckAsync("token");
+}
+catch (UpdownNotFoundException ex)
+{
+ // Handle 404 - check not found
+}
+catch (UpdownRateLimitException ex)
+{
+ // Handle 429 - rate limit exceeded
+ await Task.Delay(TimeSpan.FromSeconds(ex.RetryAfterSeconds ?? 60));
+}
+catch (UpdownUnauthorizedException ex)
+{
+ // Handle 401 - invalid API key
+}
+catch (UpdownApiException ex)
+{
+ // Handle other API errors
+}
+```
+
+### Compiler Warnings
+
+After upgrading to 2.0, you may see `CS0618` warnings about obsolete members. These are intentional and indicate areas where you should migrate to the new API patterns. The old methods will continue to work but may be removed in a future major version.
+
+To suppress these warnings temporarily while you migrate:
+```xml
+
+ $(NoWarn);CS0618
+
+```
+
+However, we recommend migrating your code to use the new patterns as soon as possible.
+
+---
+
+## Support
+
+For questions, issues, or contributions, please visit:
+- **GitHub Repository**: https://github.com/strvmarv/updown-dotnet
+- **Issue Tracker**: https://github.com/strvmarv/updown-dotnet/issues
+- **Documentation**: See `/docs` folder for detailed documentation
+
+---
+
+[2.0.0-rc.1]: https://github.com/strvmarv/updown-dotnet/compare/v1.1.0...v2.0.0-rc.1
+[1.1.0]: https://github.com/strvmarv/updown-dotnet/releases/tag/v1.1.0
+
diff --git a/README.md b/README.md
index 97e2a8f..9b737b9 100644
--- a/README.md
+++ b/README.md
@@ -1,160 +1,352 @@
[](https://github.com/strvmarv/updown-dotnet/actions/workflows/build-and-test.yml)
# updown-dotnet
-A simple [Updown.io](https://updown.io) .NET Client
+A simple, modern [Updown.io](https://updown.io) .NET Client
https://www.nuget.org/packages/UpdownDotnet
Don't currently utilize Updown.IO? Join here --> https://updown.io/r/WioVu
-# Notes
+## Features
-- This client is a simple wrapper around the Updown.io API. It does not implement all the API endpoints.
-- The client uses the System.Text.Json namespace to serialize and deserialize JSON data.
-- The client is asynchronous and uses the HttpClient class to make HTTP requests to the Updown.io API.
-- The HttpClient is implemented per Micrsoft recommendations. In this case, a Singleton that is reused.
-- You may provide your own HttpClient instance if you want to manage the lifecycle of the HttpClient.
-- Manual tests are provided if you'd like to observe the client in action. You will need to provide your own API key.
+- ✅ **Full API Coverage**: Checks, Recipients, Status Pages, Pulse Monitoring, Downtimes, Metrics, and Nodes
+- ✅ **Modern Async/Await**: All methods support async patterns with `CancellationToken`
+- ✅ **Custom Exceptions**: Specific exception types for better error handling
+- ✅ **Nullable Reference Types**: Full nullability annotations for safer code
+- ✅ **Multi-Targeting**: Supports .NET 9, .NET 8, .NET 6, and .NET Standard 2.0
+- ✅ **Comprehensive Testing**: 49+ unit tests with 100% pass rate
+- ✅ **XML Documentation**: Full IntelliSense support
-# State
+## Installation
-| Entity | Implemented |
----------|------------
-| Checks | :white_check_mark: |
-| Downtimes | :x: |
-| Metrics | :x: |
-| Nodes | :x: |
-| Recipients | :white_check_mark: |
-| Status Pages | :white_check_mark: |
-| Pulse Monitoring | :white_check_mark: |
+```bash
+dotnet add package UpdownDotnet
+```
-# Usage
+## Quick Start
-Example usage using Checks. Implementation across entities is similar. Though some entities may not support all methods.
+```csharp
+using UpdownDotnet;
+using UpdownDotnet.Models;
-***Use manual tests for reference.***
+// Create client using builder (recommended)
+var client = new UpdownClientBuilder()
+ .WithApiKey("your-api-key")
+ .Build();
-## Get all checks
-```csharp
-var client = UpdownClientFactory.Create("YOUR-API-KEY-HERE");
-var checks = await client.Checks();
-```
+// Get all checks
+var checks = await client.ChecksAsync();
-## Get check by token
-```csharp
-var client = UpdownClientFactory.Create("YOUR-API-KEY-HERE");
-var check = await client.Check("EXISTING-CHECK-TOKEN");
+// Create a new check
+var checkParams = new CheckParameters
+{
+ Url = "https://example.com",
+ Alias = "My Website",
+ Period = 60 // Check every 60 seconds
+};
+var check = await client.CheckCreateAsync(checkParams);
```
-## Create a check
-Example: Create a check for https://your-url-here.com
+## API Implementation Status
+
+| Entity | Status |
+|--------|--------|
+| Checks | ✅ Complete |
+| Downtimes | ✅ Complete |
+| Metrics | ✅ Complete |
+| Nodes | ✅ Complete |
+| Recipients | ✅ Complete |
+| Status Pages | ✅ Complete |
+| Pulse Monitoring | ✅ Complete |
+
+## Usage Examples
+
+### Checks
+
```csharp
-var client = UpdownClientFactory.Create("YOUR-API-KEY-HERE");
+// Get all checks
+var checks = await client.ChecksAsync();
+
+// Get specific check
+var check = await client.CheckAsync("check-token");
+
+// Create a check
var parameters = new CheckParameters
{
- Url = "https://your-url-here.com",
+ Url = "https://example.com",
+ Alias = "Example Site",
+ Period = 300,
+ Enabled = true
};
-var check = await client.CheckCreate(parameters);
+var newCheck = await client.CheckCreateAsync(parameters);
-```
+// Update a check
+var updateParams = new CheckParameters { Period = 600 };
+var updated = await client.CheckUpdateAsync("check-token", updateParams);
-## Update a check
-Example: Update the check period to 300 seconds
-```csharp
-var client = UpdownClientFactory.Create("YOUR-API-KEY-HERE");
-var updateParameters = new CheckParameters
-{
- Period = 300
-};
-var update = await client.CheckUpdate("EXISTING-CHECK-TOKEN", updateParameters);
+// Delete a check
+await client.CheckDeleteAsync("check-token");
```
-## Delete a check
+### Downtimes
+
```csharp
-var client = UpdownClientFactory.Create("YOUR-API-KEY-HERE");
-var delete = await client.CheckDelete("EXISTING-CHECK-TOKEN");
+// Get downtimes for a check
+var downtimes = await client.DowntimesAsync("check-token");
+// With pagination
+var page2 = await client.DowntimesAsync("check-token", page: 2);
```
-## Pulse Monitoring (Cron/Background Job Monitoring)
-
-Pulse monitoring is used to monitor cron jobs, background tasks, and scheduled processes. Unlike regular HTTP monitoring, pulse monitoring works by having **your application send heartbeat signals TO Updown.io**.
-
-To use pulse monitoring:
+### Metrics
-1. Create a pulse check in your Updown.io dashboard
-2. Updown.io will provide you with a unique pulse URL
-3. Your application/cron job should make HTTP requests TO that URL on schedule
-4. If Updown.io doesn't receive pulses within the expected timeframe, it will alert you
+```csharp
+// Get metrics for a check
+var metrics = await client.MetricsAsync("check-token");
+
+// Get metrics for a date range
+var metrics = await client.MetricsAsync(
+ "check-token",
+ from: "2024-01-01",
+ to: "2024-01-31"
+);
+
+// Group by time or location
+var metrics = await client.MetricsAsync(
+ "check-token",
+ group: "time"
+);
+```
-### Example: Sending a pulse using the client library
+### Nodes
```csharp
-var client = UpdownClientFactory.Create("YOUR-API-KEY-HERE");
+// Get all monitoring nodes
+var nodes = await client.NodesAsync();
-// Send pulse using GET request (most common)
-await client.SendPulse("https://pulse.updown.io/YOUR-TOKEN/YOUR-KEY");
+// Get IPv4 addresses
+var ipv4 = await client.NodesIpv4Async();
-// Send pulse using POST request (if needed)
-await client.SendPulsePost("https://pulse.updown.io/YOUR-TOKEN/YOUR-KEY");
+// Get IPv6 addresses
+var ipv6 = await client.NodesIpv6Async();
```
-### Example: Manual pulse (without using the client library)
-```csharp
-// Simple pulse using HttpClient directly
-using var httpClient = new HttpClient();
-await httpClient.GetAsync("https://pulse.updown.io/YOUR-TOKEN/YOUR-KEY");
+### Pulse Monitoring (Cron/Background Jobs)
-// Or using POST
-await httpClient.PostAsync("https://pulse.updown.io/YOUR-TOKEN/YOUR-KEY", null);
-```
+Pulse monitoring is used to monitor cron jobs, background tasks, and scheduled processes. Your application sends heartbeat signals to Updown.io on schedule.
-### Example: Pulse in a cron job or scheduled task
```csharp
+// Send a pulse heartbeat
+await client.SendPulseAsync("https://pulse.updown.io/YOUR-TOKEN/YOUR-KEY");
+
+// In a scheduled job
public async Task RunScheduledTask()
{
- var client = UpdownClientFactory.Create("YOUR-API-KEY-HERE");
-
try
{
- // Your actual work here
+ // Your work here
await DoImportantWork();
- // Send success pulse when work completes successfully
- await client.SendPulse("https://pulse.updown.io/YOUR-TOKEN/YOUR-KEY");
+ // Send success pulse
+ await client.SendPulseAsync(pulseUrl);
}
catch (Exception ex)
{
- // Handle error - Updown.io will detect missing pulse
- _logger.LogError(ex, "Scheduled task failed");
- // Don't send pulse on failure, so Updown.io alerts you
+ // Don't send pulse on failure - Updown.io will alert you
+ _logger.LogError(ex, "Task failed");
}
}
```
-### Example: Pulse with error handling
+## Error Handling
+
+The client provides specific exception types for different error scenarios:
+
```csharp
-public async Task SendHeartbeat(string pulseUrl)
+using UpdownDotnet.Exceptions;
+
+try
+{
+ var check = await client.CheckAsync("token");
+}
+catch (UpdownNotFoundException ex)
+{
+ // 404 - Check not found
+ Console.WriteLine("Check doesn't exist");
+}
+catch (UpdownUnauthorizedException ex)
{
- var client = UpdownClientFactory.Create("YOUR-API-KEY-HERE");
+ // 401 - Invalid API key
+ Console.WriteLine("Authentication failed");
+}
+catch (UpdownBadRequestException ex)
+{
+ // 400 - Invalid request parameters
+ Console.WriteLine($"Bad request: {ex.ResponseContent}");
+}
+catch (UpdownRateLimitException ex)
+{
+ // 429 - Rate limit exceeded
+ Console.WriteLine($"Rate limited. Retry after {ex.RetryAfterSeconds}s");
- try
+ if (ex.RetryAfterSeconds.HasValue)
{
- await client.SendPulse(pulseUrl);
- _logger.LogInformation("Pulse sent successfully");
+ await Task.Delay(TimeSpan.FromSeconds(ex.RetryAfterSeconds.Value));
+ // Retry...
}
- catch (HttpRequestException ex)
+}
+catch (UpdownApiException ex)
+{
+ // Other API errors (500+)
+ Console.WriteLine($"API error: {ex.StatusCode} - {ex.Message}");
+}
+```
+
+## ASP.NET Core Integration
+
+### Dependency Injection Setup
+
+**Program.cs**:
+```csharp
+// Register with dependency injection
+builder.Services.AddHttpClient((sp, client) =>
+{
+ client.BaseAddress = new Uri("https://updown.io");
+ var apiKey = builder.Configuration["Updown:ApiKey"];
+ client.DefaultRequestHeaders.Add("X-API-KEY", apiKey);
+});
+```
+
+**Service Implementation**:
+```csharp
+public class UpdownService : IUpdownService
+{
+ private readonly UpdownClient _client;
+
+ public UpdownService(HttpClient httpClient)
{
- _logger.LogError(ex, "Failed to send pulse");
- // Handle pulse sending failure appropriately for your application
+ _client = new UpdownClient(httpClient);
}
+
+ public async Task> GetAllChecksAsync(CancellationToken ct = default)
+ {
+ return await _client.ChecksAsync(ct);
+ }
+}
+```
+
+## Advanced Configuration
+
+### Using the Builder Pattern
+
+```csharp
+var client = new UpdownClientBuilder()
+ .WithApiKey("your-api-key")
+ .WithTimeout(TimeSpan.FromSeconds(30))
+ .WithUserAgent("MyApp/1.0")
+ .Build();
+```
+
+### Custom HttpClient
+
+```csharp
+var handler = new SocketsHttpHandler
+{
+ PooledConnectionLifetime = TimeSpan.FromMinutes(5),
+ AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
+};
+
+var client = new UpdownClientBuilder()
+ .WithApiKey("your-api-key")
+ .WithHttpMessageHandler(handler)
+ .Build();
+```
+
+### Cancellation Token Support
+
+```csharp
+// Pass cancellation token for long-running operations
+using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+
+try
+{
+ var checks = await client.ChecksAsync(cts.Token);
+}
+catch (OperationCanceledException)
+{
+ Console.WriteLine("Request cancelled");
}
```
-For more information about pulse monitoring, see: https://updown.io/doc/how-pulse-cron-monitoring-works
+## Migration from 1.x to 2.0
+
+Version 2.0 introduces several improvements while maintaining backward compatibility:
+
+**Old (still works, but deprecated):**
+```csharp
+var client = UpdownClientFactory.Create("api-key");
+var checks = await client.Checks(); // No Async suffix
+var check = await client.Check("token");
+```
+
+**New (recommended):**
+```csharp
+var client = new UpdownClientBuilder()
+ .WithApiKey("api-key")
+ .Build();
+var checks = await client.ChecksAsync(); // Async suffix
+var check = await client.CheckAsync("token");
+```
+
+For full migration details, see the [CHANGELOG](CHANGELOG.md).
+
+## Best Practices
+
+1. **Use the Builder Pattern**: More flexible and thread-safe than the factory
+ ```csharp
+ var client = new UpdownClientBuilder().WithApiKey("key").Build();
+ ```
+
+2. **Pass CancellationTokens**: Especially in web applications
+ ```csharp
+ await client.ChecksAsync(cancellationToken);
+ ```
+
+3. **Handle Specific Exceptions**: Use custom exception types for better error handling
+ ```csharp
+ catch (UpdownRateLimitException ex) { /* Handle rate limit */ }
+ ```
+
+4. **Use Async Methods**: Always use methods with `Async` suffix
+ ```csharp
+ await client.ChecksAsync(); // ✅ Good
+ await client.Checks(); // ⚠️ Deprecated
+ ```
+
+5. **Dispose HttpClient Properly**: When using custom HttpClient instances
+ ```csharp
+ using var httpClient = new HttpClient();
+ var client = new UpdownClient(httpClient);
+ ```
+
+## Troubleshooting
-# Contributing
-Use your favorite IDE to open the project. The project was developed using Visual Studio.
+### "Unauthorized" Exception
+- Verify your API key is correct
+- Check that the API key is active in your Updown.io dashboard
+
+### Rate Limiting
+- Updown.io has rate limits on API calls
+- Catch `UpdownRateLimitException` and respect `RetryAfterSeconds`
+- Consider caching responses when appropriate
+
+### Connection Timeouts
+- Default timeout is 100 seconds
+- Configure custom timeout using `UpdownClientBuilder.WithTimeout()`
+
+## Contributing
+
+Contributions are welcome! Please see our [Contributing Guide](docs/CONTRIBUTING.md) for details.
```bash
git clone https://github.com/strvmarv/updown-dotnet.git
@@ -163,6 +355,28 @@ dotnet restore
dotnet build
```
-## Run Tests
+### Run Tests
```bash
dotnet test
+```
+
+## Documentation
+
+- [API Reference](docs/API_REFERENCE.md) - Complete API documentation
+- [Architecture](docs/ARCHITECTURE.md) - Design and architecture details
+- [CHANGELOG](CHANGELOG.md) - Version history and migration guides
+- [Updown.io API Docs](https://updown.io/api) - Official API documentation
+
+## License
+
+MIT License - see [LICENSE](LICENSE) file for details
+
+## Support
+
+- 🐛 [Report a bug](https://github.com/strvmarv/updown-dotnet/issues)
+- 💡 [Request a feature](https://github.com/strvmarv/updown-dotnet/issues)
+- 📖 [View documentation](docs/)
+
+---
+
+Made with ❤️ for the .NET community
diff --git a/SECURITY.md b/SECURITY.md
index 8119744..86f0432 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,14 +2,133 @@
## Supported Versions
-| Version | Supported |
-| ------- | ------------------ |
-| 1.0.x | :white_check_mark: |
-| < 1.0 | :x: |
+We actively support the following versions with security updates:
+
+| Version | Supported | End of Support |
+| ------- | ------------------ | -------------- |
+| 2.0.x | :white_check_mark: | Active |
+| 1.1.x | :white_check_mark: | 2026-01-31 |
+| 1.0.x | :x: | 2025-10-21 |
+| < 1.0 | :x: | End of Life |
## Reporting a Vulnerability
-Create an issue in the repository to get the attention of the owner, who will reach
-out directly to you for details. Please do not post details in the issue.
+If you discover a security vulnerability in this project, please help us address it responsibly:
+
+1. **DO NOT** open a public GitHub issue for security vulnerabilities
+2. **DO** create a [security advisory](https://github.com/strvmarv/updown-dotnet/security/advisories/new) using GitHub's private reporting feature
+3. **DO** include:
+ - Description of the vulnerability
+ - Steps to reproduce
+ - Potential impact
+ - Suggested fix (if available)
+
+We will:
+- Acknowledge receipt within 48 hours
+- Provide an estimated timeline for a fix
+- Notify you when the vulnerability is fixed
+- Credit you in the security advisory (unless you prefer to remain anonymous)
+
+## API Key Security
+
+### Best Practices
+
+When using this library:
+
+✅ **DO:**
+- Store API keys in environment variables or secure configuration
+- Use separate API keys for development, testing, and production
+- Rotate API keys regularly
+- Use .NET Secret Manager for local development
+- Add `*.env`, `*secrets.json` to `.gitignore`
+- Use Azure Key Vault, AWS Secrets Manager, or similar for production
+
+❌ **DON'T:**
+- Hardcode API keys in source code
+- Commit API keys to version control
+- Share API keys in issues or pull requests
+- Log API keys in application logs
+- Include API keys in client-side code
+
+### For Contributors
+
+If you're contributing to this project:
+
+1. **Never commit real API keys** - All test files use placeholders like `"YOUR-API-KEY-HERE"`
+2. **Check before committing:**
+ ```bash
+ git diff --cached | grep -i "api.key"
+ git diff --cached | grep -E "[a-z0-9]{32,}"
+ ```
+3. **Use environment variables** for manual testing - See `.env.example`
+4. **Configuration files** with secrets are git-ignored - See `.gitignore`
+
+### For Users
+
+To keep your API keys secure:
+
+```csharp
+// ✅ Good: Environment variable
+var apiKey = Environment.GetEnvironmentVariable("UPDOWN_API_KEY");
+var client = new UpdownClientBuilder()
+ .WithApiKey(apiKey)
+ .Build();
+
+// ✅ Good: Configuration (with User Secrets in development)
+var apiKey = configuration["Updown:ApiKey"];
+var client = new UpdownClientBuilder()
+ .WithApiKey(apiKey)
+ .Build();
+
+// ❌ Bad: Hardcoded
+var client = new UpdownClientBuilder()
+ .WithApiKey("ro-abc123xyz...") // NEVER DO THIS
+ .Build();
+```
+
+### Setting Up User Secrets (Recommended for Development)
+
+```bash
+# Navigate to your project directory
+cd YourProject/
+
+# Initialize user secrets
+dotnet user-secrets init
+
+# Set your API key
+dotnet user-secrets set "Updown:ApiKey" "your-api-key-here"
+```
+
+In your code:
+```csharp
+// IConfiguration is injected via DI
+var apiKey = configuration["Updown:ApiKey"];
+```
+
+## Dependencies
+
+We regularly update dependencies to address security vulnerabilities. To check for updates:
+
+```bash
+dotnet list package --outdated
+dotnet list package --vulnerable
+```
+
+## Security Features in This Library
+
+- **No credential storage** - API keys are only held in memory during requests
+- **HTTPS only** - All API calls use TLS 1.2+
+- **Input validation** - Parameters are validated before sending
+- **No logging of sensitive data** - API keys are never logged
+- **Thread-safe** - `HttpClient` management follows Microsoft best practices
+
+## Additional Resources
+
+- [OWASP API Security Top 10](https://owasp.org/www-project-api-security/)
+- [.NET Security Best Practices](https://docs.microsoft.com/en-us/dotnet/standard/security/)
+- [Managing Secrets in .NET](https://docs.microsoft.com/en-us/aspnet/core/security/app-secrets)
+- [Updown.io Security](https://updown.io/security)
+
+---
-[https://github.com/strvmarv/updown-dotnet/issues](https://github.com/strvmarv/updown-dotnet/issues)
+Last Updated: 2025-10-21
diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md
new file mode 100644
index 0000000..27e761c
--- /dev/null
+++ b/docs/API_REFERENCE.md
@@ -0,0 +1,585 @@
+# Updown.io .NET Client API Reference
+
+## Table of Contents
+- [Client Creation](#client-creation)
+- [Checks API](#checks-api)
+- [Downtimes API](#downtimes-api)
+- [Metrics API](#metrics-api)
+- [Nodes API](#nodes-api)
+- [Recipients API](#recipients-api)
+- [Status Pages API](#status-pages-api)
+- [Pulse Monitoring](#pulse-monitoring)
+- [Exception Types](#exception-types)
+- [Models](#models)
+
+## Client Creation
+
+### `UpdownClientFactory.Create(string apiKey)`
+Creates a simple client with the specified API key.
+
+```csharp
+var client = UpdownClientFactory.Create("your-api-key");
+```
+
+**Note**: This creates a new HttpClient instance. For production use, consider using the Builder pattern or injecting your own HttpClient.
+
+### `UpdownClientFactory.CreateBuilder()`
+Returns a fluent builder for advanced configuration.
+
+```csharp
+var client = UpdownClientFactory.CreateBuilder()
+ .WithApiKey("your-api-key")
+ .WithTimeout(TimeSpan.FromSeconds(30))
+ .WithUserAgent("MyApp/1.0")
+ .Build();
+```
+
+### `UpdownClientFactory.Create(HttpClient httpClient)`
+Creates a client using your own HttpClient instance.
+
+```csharp
+var httpClient = new HttpClient
+{
+ BaseAddress = new Uri("https://updown.io")
+};
+httpClient.DefaultRequestHeaders.Add("X-API-KEY", "your-api-key");
+
+var client = UpdownClientFactory.Create(httpClient);
+```
+
+## Checks API
+
+### `ChecksAsync(CancellationToken cancellationToken = default)`
+Gets all checks for your account.
+
+**Returns**: `Task>`
+
+```csharp
+var checks = await client.ChecksAsync();
+foreach (var check in checks)
+{
+ Console.WriteLine($"{check.Alias}: {check.Url} - {(check.Down == true ? "DOWN" : "UP")}");
+}
+```
+
+### `CheckAsync(string token, CancellationToken cancellationToken = default)`
+Gets a specific check by its token.
+
+**Parameters**:
+- `token` - The check token
+- `cancellationToken` - Optional cancellation token
+
+**Returns**: `Task`
+
+**Throws**:
+- `ArgumentException` - When token is null or empty
+- `UpdownNotFoundException` - When check doesn't exist
+
+```csharp
+var check = await client.CheckAsync("abc123");
+Console.WriteLine($"Last checked: {check.LastCheckAt}");
+```
+
+### `CheckCreateAsync(CheckParameters parameters, CancellationToken cancellationToken = default)`
+Creates a new check.
+
+**Parameters**:
+- `parameters` - Check configuration parameters
+- `cancellationToken` - Optional cancellation token
+
+**Returns**: `Task`
+
+**Throws**:
+- `ArgumentNullException` - When parameters is null
+- `UpdownBadRequestException` - When parameters are invalid
+
+```csharp
+var parameters = new CheckParameters
+{
+ Url = "https://example.com",
+ Alias = "My Website",
+ Period = 60, // Check every 60 seconds
+ Enabled = true
+};
+
+var check = await client.CheckCreateAsync(parameters);
+Console.WriteLine($"Check created with token: {check.Token}");
+```
+
+### `CheckUpdateAsync(string token, CheckParameters parameters, CancellationToken cancellationToken = default)`
+Updates an existing check.
+
+**Parameters**:
+- `token` - The check token
+- `parameters` - Updated check parameters
+- `cancellationToken` - Optional cancellation token
+
+**Returns**: `Task`
+
+```csharp
+var updateParams = new CheckParameters
+{
+ Period = 300, // Change to 5 minutes
+ Enabled = false // Disable the check
+};
+
+var updated = await client.CheckUpdateAsync("abc123", updateParams);
+```
+
+### `CheckDeleteAsync(string token, CancellationToken cancellationToken = default)`
+Deletes a check.
+
+**Parameters**:
+- `token` - The check token
+- `cancellationToken` - Optional cancellation token
+
+**Returns**: `Task`
+
+```csharp
+var result = await client.CheckDeleteAsync("abc123");
+if (result.Deleted)
+{
+ Console.WriteLine("Check deleted successfully");
+}
+```
+
+## Downtimes API
+
+### `DowntimesAsync(string token, int? page = null, CancellationToken cancellationToken = default)`
+Gets downtime history for a specific check.
+
+**Parameters**:
+- `token` - The check token
+- `page` - Optional page number for pagination
+- `cancellationToken` - Optional cancellation token
+
+**Returns**: `Task>`
+
+```csharp
+var downtimes = await client.DowntimesAsync("abc123");
+foreach (var downtime in downtimes)
+{
+ Console.WriteLine($"Down from {downtime.StartedAt} to {downtime.EndedAt}");
+ Console.WriteLine($"Duration: {downtime.Duration} seconds");
+ Console.WriteLine($"Error: {downtime.Error}");
+}
+```
+
+## Metrics API
+
+### `MetricsAsync(string token, string? from = null, string? to = null, string? group = null, CancellationToken cancellationToken = default)`
+Gets performance metrics for a check.
+
+**Parameters**:
+- `token` - The check token
+- `from` - Optional start time (ISO 8601 format or UNIX timestamp)
+- `to` - Optional end time (ISO 8601 format or UNIX timestamp)
+- `group` - Optional grouping interval ("time" or "host")
+- `cancellationToken` - Optional cancellation token
+
+**Returns**: `Task>`
+
+```csharp
+// Get metrics for the last 24 hours
+var yesterday = DateTimeOffset.UtcNow.AddDays(-1).ToString("o");
+var now = DateTimeOffset.UtcNow.ToString("o");
+
+var metrics = await client.MetricsAsync("abc123", from: yesterday, to: now);
+foreach (var metric in metrics)
+{
+ Console.WriteLine($"Apdex: {metric.Apdex}");
+ Console.WriteLine($"Response time: {metric.Timings?.Total}ms");
+ Console.WriteLine($"Requests: {metric.Requests?.Samples}");
+}
+```
+
+## Nodes API
+
+### `NodesAsync(CancellationToken cancellationToken = default)`
+Gets all monitoring nodes/locations.
+
+**Returns**: `Task>`
+
+```csharp
+var nodes = await client.NodesAsync();
+foreach (var node in nodes)
+{
+ Console.WriteLine($"{node.Name} - {node.City}, {node.Country}");
+ Console.WriteLine($"Location: {node.Latitude}, {node.Longitude}");
+}
+```
+
+### `NodesIpv4Async(CancellationToken cancellationToken = default)`
+Gets all IPv4 addresses used by monitoring nodes.
+
+**Returns**: `Task`
+
+```csharp
+var addresses = await client.NodesIpv4Async();
+// Use for firewall whitelisting
+foreach (var ip in addresses.Ipv4 ?? Enumerable.Empty())
+{
+ Console.WriteLine($"Whitelist: {ip}");
+}
+```
+
+### `NodesIpv6Async(CancellationToken cancellationToken = default)`
+Gets all IPv6 addresses used by monitoring nodes.
+
+**Returns**: `Task`
+
+```csharp
+var addresses = await client.NodesIpv6Async();
+foreach (var ip in addresses.Ipv6 ?? Enumerable.Empty())
+{
+ Console.WriteLine($"Whitelist IPv6: {ip}");
+}
+```
+
+## Recipients API
+
+### `RecipientsAsync(CancellationToken cancellationToken = default)`
+Gets all notification recipients.
+
+**Returns**: `Task>`
+
+```csharp
+var recipients = await client.RecipientsAsync();
+foreach (var recipient in recipients)
+{
+ Console.WriteLine($"{recipient.Name} ({recipient.Type}): {recipient.Value}");
+}
+```
+
+### `RecipientCreateAsync(RecipientParameters parameters, CancellationToken cancellationToken = default)`
+Creates a new notification recipient.
+
+**Parameters**:
+- `parameters` - Recipient configuration
+- `cancellationToken` - Optional cancellation token
+
+**Returns**: `Task`
+
+```csharp
+var parameters = new RecipientParameters
+{
+ Type = "email",
+ Name = "John Doe",
+ Value = "john@example.com"
+};
+
+var recipient = await client.RecipientCreateAsync(parameters);
+```
+
+**Recipient Types**:
+- `email` - Email address
+- `slack` - Slack webhook URL
+- `webhook` - Custom webhook URL
+
+### `RecipientDeleteAsync(string token, CancellationToken cancellationToken = default)`
+Deletes a recipient.
+
+**Parameters**:
+- `token` - The recipient ID
+- `cancellationToken` - Optional cancellation token
+
+**Returns**: `Task`
+
+```csharp
+await client.RecipientDeleteAsync("recipient-id");
+```
+
+## Status Pages API
+
+### `StatusPagesAsync(CancellationToken cancellationToken = default)`
+Gets all status pages.
+
+**Returns**: `Task>`
+
+```csharp
+var pages = await client.StatusPagesAsync();
+foreach (var page in pages)
+{
+ Console.WriteLine($"{page.Name}: {page.Url}");
+}
+```
+
+### `StatusPageCreateAsync(StatusPageParameters parameters, CancellationToken cancellationToken = default)`
+Creates a new status page.
+
+**Parameters**:
+- `parameters` - Status page configuration
+- `cancellationToken` - Optional cancellation token
+
+**Returns**: `Task`
+
+```csharp
+var parameters = new StatusPageParameters
+{
+ Name = "Service Status",
+ Description = "Current status of our services",
+ Visibility = "public",
+ Checks = new List { "check-token-1", "check-token-2" }
+};
+
+var page = await client.StatusPageCreateAsync(parameters);
+```
+
+### `StatusPageUpdateAsync(string token, StatusPageParameters parameters, CancellationToken cancellationToken = default)`
+Updates a status page.
+
+**Returns**: `Task`
+
+```csharp
+var updateParams = new StatusPageParameters
+{
+ Name = "Updated Status Page",
+ Visibility = "private",
+ AccessKey = "secret-key"
+};
+
+await client.StatusPageUpdateAsync("page-token", updateParams);
+```
+
+### `StatusPageDeleteAsync(string token, CancellationToken cancellationToken = default)`
+Deletes a status page.
+
+**Returns**: `Task`
+
+```csharp
+await client.StatusPageDeleteAsync("page-token");
+```
+
+## Pulse Monitoring
+
+Pulse monitoring is used for cron job and background task monitoring. Your application sends heartbeats TO Updown.io.
+
+### `SendPulseAsync(string pulseUrl, CancellationToken cancellationToken = default)`
+Sends a heartbeat pulse using GET request.
+
+**Parameters**:
+- `pulseUrl` - The complete pulse URL from Updown.io
+- `cancellationToken` - Optional cancellation token
+
+**Returns**: `Task`
+
+```csharp
+// In your cron job or background task
+public async Task RunScheduledTask()
+{
+ var client = UpdownClientFactory.Create("your-api-key");
+ var pulseUrl = "https://pulse.updown.io/your-token/your-key";
+
+ try
+ {
+ // Do your work
+ await DoWork();
+
+ // Send success pulse
+ await client.SendPulseAsync(pulseUrl);
+ }
+ catch (Exception ex)
+ {
+ // Don't send pulse on failure - Updown.io will alert
+ _logger.LogError(ex, "Task failed");
+ }
+}
+```
+
+### `SendPulsePostAsync(string pulseUrl, HttpContent? content = null, CancellationToken cancellationToken = default)`
+Sends a heartbeat pulse using POST request with optional content.
+
+**Parameters**:
+- `pulseUrl` - The complete pulse URL from Updown.io
+- `content` - Optional HTTP content to send
+- `cancellationToken` - Optional cancellation token
+
+**Returns**: `Task`
+
+```csharp
+var content = new StringContent("Task completed successfully");
+await client.SendPulsePostAsync(pulseUrl, content);
+```
+
+## Exception Types
+
+### `UpdownApiException`
+Base exception for all Updown.io API errors.
+
+**Properties**:
+- `StatusCode` - HTTP status code
+- `ResponseContent` - Raw response body
+- `Message` - Error message
+
+### `UpdownNotFoundException`
+Thrown when a resource is not found (HTTP 404).
+
+```csharp
+try
+{
+ var check = await client.CheckAsync("nonexistent");
+}
+catch (UpdownNotFoundException ex)
+{
+ Console.WriteLine($"Check not found: {ex.Message}");
+}
+```
+
+### `UpdownUnauthorizedException`
+Thrown when authentication fails (HTTP 401 or 403).
+
+```csharp
+catch (UpdownUnauthorizedException ex)
+{
+ Console.WriteLine("Invalid API key or insufficient permissions");
+}
+```
+
+### `UpdownBadRequestException`
+Thrown when the request is invalid (HTTP 400).
+
+```csharp
+catch (UpdownBadRequestException ex)
+{
+ Console.WriteLine($"Invalid request: {ex.ResponseContent}");
+}
+```
+
+### `UpdownRateLimitException`
+Thrown when API rate limit is exceeded (HTTP 429).
+
+**Additional Properties**:
+- `RetryAfterSeconds` - Seconds until rate limit resets
+- `ResetTime` - DateTimeOffset when limit resets
+
+```csharp
+catch (UpdownRateLimitException ex)
+{
+ Console.WriteLine($"Rate limited. Retry after {ex.RetryAfterSeconds} seconds");
+ if (ex.ResetTime.HasValue)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(ex.RetryAfterSeconds ?? 60));
+ // Retry...
+ }
+}
+```
+
+## Models
+
+### Check
+Represents an HTTP monitoring check.
+
+**Key Properties**:
+- `Token` - Unique check identifier
+- `Url` - URL being monitored
+- `Alias` - Friendly name
+- `Down` - Whether check is currently down
+- `LastStatus` - Last HTTP status code
+- `Uptime` - Uptime percentage
+- `Period` - Check interval in seconds
+- `Enabled` - Whether check is active
+- `LastCheckAt` - Time of last check
+- `NextCheckAt` - Time of next scheduled check
+
+### CheckParameters
+Parameters for creating/updating checks.
+
+**Properties**:
+- `Url` - URL to monitor
+- `Alias` - Friendly name
+- `Period` - Check interval (15, 30, 60, 120, 300, 600, 1800, 3600)
+- `ApdexT` - Apdex threshold in seconds (0.125-8)
+- `Enabled` - Enable/disable check
+- `Published` - Publish on status page
+- `StringMatch` - String to find in response
+- `CustomHeaders` - Custom HTTP headers
+- `HttpVerb` - HTTP method (GET, POST, etc.)
+- `HttpBody` - Request body
+- `Recipients` - List of recipient IDs to notify
+
+### Downtime
+Represents a downtime period.
+
+**Properties**:
+- `Error` - Error message
+- `StartedAt` - When downtime started
+- `EndedAt` - When downtime ended (null if still down)
+- `Duration` - Duration in seconds
+
+### Metric
+Performance metrics for a time period.
+
+**Properties**:
+- `Time` - Timestamp (UNIX epoch)
+- `Apdex` - Apdex score (0-1)
+- `Requests` - Request statistics
+- `Timings` - Response time breakdown
+
+### Node
+Monitoring node/location.
+
+**Properties**:
+- `Name` - Node identifier
+- `City` - City name
+- `Country` - Country name
+- `CountryCode` - ISO country code
+- `Latitude` - Geographic latitude
+- `Longitude` - Geographic longitude
+- `IpAddress` - IPv4 address
+- `Ipv6Address` - IPv6 address
+
+### Recipient
+Notification recipient.
+
+**Properties**:
+- `Id` - Unique identifier
+- `Type` - Recipient type (email, slack, webhook)
+- `Name` - Display name
+- `Value` - Contact value (email, URL, etc.)
+
+### StatusPage
+Public status page.
+
+**Properties**:
+- `Token` - Unique identifier
+- `Url` - Public URL
+- `Name` - Page name
+- `Description` - Page description
+- `Visibility` - public or private
+- `AccessKey` - Access key for private pages
+- `Checks` - List of check tokens to display
+
+## Rate Limiting
+
+Updown.io enforces rate limits:
+- **Free plans**: 60 requests/minute
+- **Paid plans**: Higher limits
+
+When rate limited:
+1. Catch `UpdownRateLimitException`
+2. Check `RetryAfterSeconds` property
+3. Wait before retrying
+4. Consider implementing exponential backoff
+
+## Authentication
+
+All requests require an API key:
+1. Get your API key from https://updown.io/settings/edit
+2. Pass to client creation
+3. Never commit API keys to source control
+4. Use environment variables or secure vaults
+
+## Best Practices
+
+1. **Reuse HttpClient**: Don't create a new client for each request
+2. **Handle Exceptions**: Wrap API calls in try-catch blocks
+3. **Use Cancellation Tokens**: Support request cancellation
+4. **Log Appropriately**: Don't log API keys or sensitive data
+5. **Respect Rate Limits**: Implement retry logic with backoff
+6. **Use Async/Await**: Don't block with `.Result` or `.Wait()`
+7. **Dispose Properly**: If you create HttpClient, dispose it when done
+
+## Examples
+
+See the [README.md](../README.md) for comprehensive usage examples.
+
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..02146ae
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,358 @@
+# Updown.io .NET Client Architecture
+
+## Overview
+
+The Updown.io .NET client is a lightweight, asynchronous HTTP API client library that provides a strongly-typed interface for interacting with the Updown.io monitoring service. The library is designed with modern .NET practices, including nullable reference types, comprehensive error handling, and full async/await support.
+
+## Design Philosophy
+
+### 1. **Simplicity First**
+The library aims to be as simple to use as possible while remaining powerful. Most common operations can be completed with just a few lines of code.
+
+### 2. **Modern .NET Standards**
+- Full support for async/await patterns
+- Nullable reference types enabled
+- CancellationToken support throughout
+- Proper exception handling with custom exception types
+- XML documentation for IntelliSense support
+
+### 3. **Backward Compatibility**
+The library maintains backward compatibility through:
+- Obsolete attributes on deprecated methods
+- Maintaining old property names while introducing better-named alternatives
+- Careful versioning strategies
+
+### 4. **Multi-Target Support**
+Supports multiple target frameworks:
+- .NET 9.0
+- .NET 8.0
+- .NET 6.0
+- .NET Standard 2.0
+
+## Architecture Components
+
+### Core Classes
+
+#### `UpdownClient`
+The main entry point for all API operations. Implemented as a partial class to organize API methods into logical groups:
+- `ApiChecks` - HTTP monitoring checks
+- `ApiRecipients` - Notification recipients
+- `ApiStatusPages` - Public status pages
+- `ApiPulse` - Heartbeat monitoring
+- `ApiDowntimes` - Downtime history
+- `ApiMetrics` - Performance metrics
+- `ApiNodes` - Monitoring node information
+
+#### `UpdownClientBase`
+Base class providing:
+- HTTP request/response handling
+- JSON serialization/deserialization
+- Error handling and exception mapping
+- Common HTTP method wrappers (GET, POST, PUT, DELETE)
+
+#### `UpdownClientFactory`
+Factory class for creating `UpdownClient` instances:
+- Static methods for simple creation
+- Builder pattern support for advanced configuration
+- Handles HttpClient lifecycle recommendations
+
+#### `UpdownClientBuilder`
+Fluent builder for configuring clients:
+- API key configuration
+- Custom HttpClient injection
+- Base address customization
+- Timeout configuration
+- User agent customization
+
+## HttpClient Lifecycle Management
+
+### The HttpClient Problem
+HttpClient should be reused rather than created per request to avoid socket exhaustion. However, the static singleton pattern has thread-safety issues when mutating headers.
+
+### Our Solution
+
+#### Option 1: Provide Your Own HttpClient (Recommended for ASP.NET Core)
+```csharp
+// In ASP.NET Core Startup/Program.cs
+services.AddHttpClient((serviceProvider, client) =>
+{
+ client.BaseAddress = new Uri("https://updown.io");
+ client.DefaultRequestHeaders.Add("X-API-KEY", "your-api-key");
+});
+```
+
+#### Option 2: Use the Builder Pattern (Recommended for Console Apps)
+```csharp
+var client = UpdownClientFactory.CreateBuilder()
+ .WithApiKey("your-api-key")
+ .WithTimeout(TimeSpan.FromSeconds(30))
+ .Build();
+```
+
+#### Option 3: Simple Factory Method (Creates New HttpClient Per Call)
+```csharp
+var client = UpdownClientFactory.Create("your-api-key");
+```
+
+## Error Handling Strategy
+
+### Custom Exception Hierarchy
+
+```
+Exception
+└── UpdownApiException (base for all API errors)
+ ├── UpdownNotFoundException (404)
+ ├── UpdownUnauthorizedException (401/403)
+ ├── UpdownBadRequestException (400)
+ └── UpdownRateLimitException (429)
+```
+
+### Error Handling Flow
+
+1. **HTTP Request** → Made via HttpClient
+2. **Response Check** → `HandleErrorResponseAsync` examines status code
+3. **Exception Creation** → Specific exception type created based on status
+4. **Error Details** → Response content captured for debugging
+5. **Throw** → Exception thrown to caller
+
+### Example Error Handling
+
+```csharp
+try
+{
+ var check = await client.CheckAsync("token");
+}
+catch (UpdownNotFoundException ex)
+{
+ // Handle 404 - check doesn't exist
+ Console.WriteLine($"Check not found: {ex.Message}");
+}
+catch (UpdownUnauthorizedException ex)
+{
+ // Handle authentication failure
+ Console.WriteLine($"Auth failed: {ex.Message}");
+}
+catch (UpdownRateLimitException ex)
+{
+ // Handle rate limiting
+ Console.WriteLine($"Rate limited. Retry after {ex.RetryAfterSeconds} seconds");
+ if (ex.ResetTime.HasValue)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(ex.RetryAfterSeconds ?? 60));
+ // Retry request...
+ }
+}
+catch (UpdownApiException ex)
+{
+ // Handle other API errors
+ Console.WriteLine($"API error ({ex.StatusCode}): {ex.Message}");
+ Console.WriteLine($"Response: {ex.ResponseContent}");
+}
+```
+
+## Threading and Async Patterns
+
+### Async All the Way
+All API methods are fully asynchronous:
+- Use `ConfigureAwait(false)` to avoid deadlocks
+- Support `CancellationToken` for request cancellation
+- Properly dispose resources in async methods
+
+### Thread Safety
+- `UpdownClient` instances are thread-safe for concurrent requests
+- HttpClient is designed to be reused across threads
+- No shared mutable state in the client
+
+### Example with Cancellation
+
+```csharp
+var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+
+try
+{
+ var checks = await client.ChecksAsync(cts.Token);
+}
+catch (OperationCanceledException)
+{
+ Console.WriteLine("Request cancelled after 10 seconds");
+}
+```
+
+## JSON Serialization
+
+### Configuration
+Uses `System.Text.Json` with the following options:
+- `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
+- Ignores null values when serializing requests
+- Gracefully handles missing properties when deserializing
+
+### Property Naming
+- Uses `JsonPropertyName` attributes for API field mapping
+- Maintains C# naming conventions (PascalCase) in models
+- Maps to API conventions (snake_case) via attributes
+
+Example:
+```csharp
+[JsonPropertyName("last_check_at")]
+public DateTimeOffset? LastCheckAt { get; set; }
+```
+
+## Extension Points
+
+### Custom HttpClient Handlers
+You can inject custom `HttpClientHandler` or `DelegatingHandler` instances:
+
+```csharp
+var handler = new SocketsHttpHandler
+{
+ PooledConnectionLifetime = TimeSpan.FromMinutes(5),
+ AutomaticDecompression = DecompressionMethods.All
+};
+
+var httpClient = new HttpClient(handler)
+{
+ BaseAddress = new Uri("https://updown.io")
+};
+
+httpClient.DefaultRequestHeaders.Add("X-API-KEY", "your-key");
+
+var client = UpdownClientFactory.Create(httpClient);
+```
+
+### Custom Error Handling
+Override `HandleErrorResponseAsync` in a derived class:
+
+```csharp
+public class CustomUpdownClient : UpdownClient
+{
+ public CustomUpdownClient(HttpClient httpClient) : base(httpClient) { }
+
+ protected override async Task HandleErrorResponseAsync(
+ HttpResponseMessage response,
+ CancellationToken cancellationToken)
+ {
+ // Custom error logging
+ _logger.LogError($"API error: {response.StatusCode}");
+
+ // Call base implementation
+ await base.HandleErrorResponseAsync(response, cancellationToken);
+ }
+}
+```
+
+## Model Design
+
+### Immutability vs Mutability
+Models use get/set properties (mutable) for:
+- Deserialization from API responses
+- Easy property setting for request parameters
+- Backward compatibility
+
+Future versions may introduce record types for immutable response models.
+
+### Nullable Reference Types
+All properties are properly annotated:
+- Required properties: non-nullable types
+- Optional properties: nullable types
+- Helps prevent null reference exceptions at compile time
+
+### Backward Compatibility
+Old property names are marked as `[Obsolete]` and delegate to new properties:
+
+```csharp
+[JsonPropertyName("last_status")]
+public double? LastStatus { get; set; }
+
+[Obsolete("Use LastStatus instead.")]
+[JsonIgnore]
+public double? Last_Status
+{
+ get => LastStatus;
+ set => LastStatus = value;
+}
+```
+
+## Performance Considerations
+
+### Connection Pooling
+- Uses `SocketsHttpHandler` on .NET 5+ for connection pooling
+- Configures `PooledConnectionLifetime` to prevent DNS issues
+- Enables automatic decompression for reduced bandwidth
+
+### Memory Allocation
+- Uses `ReadAsStreamAsync` for JSON deserialization (avoids string allocation)
+- Configures JSON serializer options once and reuses
+- Minimal allocations per request
+
+### Async/Await Overhead
+- Uses `ValueTask` where appropriate (future enhancement)
+- Avoids synchronous blocking calls
+- Properly implements async methods throughout the stack
+
+## Testing Strategy
+
+### Unit Tests
+- Mock HTTP responses using WireMock.NET
+- Test each API endpoint independently
+- Verify request formatting and response parsing
+- Test error handling paths
+
+### Integration Tests
+- Optional tests marked with `[Category("Integration")]`
+- Run against real Updown.io API
+- Require valid API key
+- Should be run sparingly to avoid API rate limits
+
+## Future Enhancements
+
+### Planned Improvements
+1. **Retry Policies** - Automatic retry with exponential backoff using Polly
+2. **Rate Limiting** - Client-side rate limiting to prevent 429 errors
+3. **Caching** - Optional caching layer for frequently accessed data
+4. **Webhooks** - Support for receiving Updown.io webhook notifications
+5. **Reactive Extensions** - IObservable-based API for real-time monitoring
+6. **Source Generators** - Compile-time code generation for improved performance
+
+### Breaking Changes in Future Versions
+- Migration to record types for response models
+- IAsyncEnumerable support for paginated results
+- Removal of obsolete methods and properties
+- Stricter nullability annotations
+
+## Dependency Management
+
+### Minimal Dependencies
+The library has minimal external dependencies:
+- `System.Text.Json` (only for .NET 6.0 and netstandard2.0)
+- .NET 8.0+ includes this in the framework
+
+### Versioning Strategy
+Follows semantic versioning (SemVer):
+- **MAJOR**: Breaking changes
+- **MINOR**: New features, backward-compatible
+- **PATCH**: Bug fixes, backward-compatible
+
+## Security Considerations
+
+### API Key Handling
+- Never log API keys
+- Store keys in secure configuration (Azure Key Vault, AWS Secrets Manager, etc.)
+- Use environment variables or user secrets in development
+- Rotate keys regularly
+
+### HTTPS Only
+- All communication over HTTPS
+- Certificate validation enabled by default
+- No support for HTTP (by design)
+
+### Sensitive Data
+- Avoid logging full API responses (may contain sensitive check URLs)
+- Sanitize errors before presenting to end users
+- Use structured logging with appropriate log levels
+
+## Resources
+
+- [Updown.io API Documentation](https://updown.io/api)
+- [Microsoft HttpClient Best Practices](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests)
+- [Nullable Reference Types](https://docs.microsoft.com/en-us/dotnet/csharp/nullable-references)
+
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
new file mode 100644
index 0000000..998c8b3
--- /dev/null
+++ b/docs/CONTRIBUTING.md
@@ -0,0 +1,412 @@
+# Contributing to Updown.io .NET Client
+
+Thank you for considering contributing to the Updown.io .NET Client! This document provides guidelines and instructions for contributing to the project.
+
+## Table of Contents
+- [Code of Conduct](#code-of-conduct)
+- [Getting Started](#getting-started)
+- [Development Setup](#development-setup)
+- [Project Structure](#project-structure)
+- [Coding Standards](#coding-standards)
+- [Testing Guidelines](#testing-guidelines)
+- [Pull Request Process](#pull-request-process)
+- [Release Process](#release-process)
+
+## Code of Conduct
+
+This project adheres to a code of conduct that all contributors are expected to follow:
+
+- Be respectful and inclusive
+- Welcome newcomers and help them learn
+- Focus on what is best for the community
+- Show empathy towards other community members
+- Be patient with questions and different perspectives
+
+## Getting Started
+
+### Prerequisites
+- .NET SDK 6.0 or later
+- Git
+- A code editor (Visual Studio, VS Code, or Rider recommended)
+- An Updown.io account (for integration testing)
+
+### Fork and Clone
+
+1. Fork the repository on GitHub
+2. Clone your fork locally:
+```bash
+git clone https://github.com/YOUR-USERNAME/updown-dotnet.git
+cd updown-dotnet
+```
+
+3. Add the upstream repository:
+```bash
+git remote add upstream https://github.com/strvmarv/updown-dotnet.git
+```
+
+## Development Setup
+
+### Building the Project
+
+```bash
+# Restore dependencies
+dotnet restore
+
+# Build the project
+dotnet build
+
+# Run tests
+dotnet test
+```
+
+### Running Tests
+
+```bash
+# Run all tests
+dotnet test
+
+# Run tests for specific framework
+dotnet test --framework net8.0
+
+# Run with detailed output
+dotnet test --verbosity detailed
+
+# Run tests with coverage
+dotnet test /p:CollectCoverage=true
+```
+
+### Integration Tests
+
+Integration tests are marked with `[Category("Integration")]` and require a valid API key:
+
+1. Set your API key in environment variable:
+```bash
+# Windows PowerShell
+$env:UPDOWN_API_KEY = "your-api-key"
+
+# Linux/Mac
+export UPDOWN_API_KEY="your-api-key"
+```
+
+2. Run integration tests:
+```bash
+dotnet test --filter Category=Integration
+```
+
+**Important**: Integration tests create real resources. Clean up after testing to avoid charges.
+
+## Project Structure
+
+```
+updown-dotnet/
+├── src/
+│ ├── Apis/ # API endpoint implementations
+│ │ ├── ApiChecks.cs
+│ │ ├── ApiDowntimes.cs
+│ │ ├── ApiMetrics.cs
+│ │ ├── ApiNodes.cs
+│ │ ├── ApiPulse.cs
+│ │ ├── ApiRecipients.cs
+│ │ └── ApiStatusPages.cs
+│ ├── Exceptions/ # Custom exception types
+│ ├── Models/ # Request/response models
+│ ├── UpdownClient.cs # Main client class
+│ ├── UpdownClientBase.cs # Base client functionality
+│ ├── UpdownClientBuilder.cs # Fluent builder
+│ └── UpdownClientFactory.cs # Factory methods
+├── test/
+│ └── UpdownDotNetTests/
+│ ├── Checks/ # Check API tests
+│ ├── Downtimes/ # Downtime API tests
+│ ├── Metrics/ # Metrics API tests
+│ ├── Nodes/ # Nodes API tests
+│ ├── Pulse/ # Pulse API tests
+│ ├── Recipients/ # Recipients API tests
+│ └── StatusPages/ # Status pages API tests
+└── docs/
+ ├── ARCHITECTURE.md # Architecture documentation
+ ├── API_REFERENCE.md # API reference
+ └── CONTRIBUTING.md # This file
+```
+
+## Coding Standards
+
+### C# Conventions
+
+- Follow standard C# naming conventions (PascalCase for types, camelCase for locals)
+- Use meaningful variable and method names
+- Keep methods focused and small (single responsibility)
+- Use nullable reference types consistently
+- Add XML documentation comments to all public APIs
+
+### Code Style
+
+```csharp
+// Good
+///
+/// Gets a specific check by its token.
+///
+/// The check token.
+/// Cancellation token.
+/// The check details.
+/// Thrown when token is null or empty.
+public async Task CheckAsync(string token, CancellationToken cancellationToken = default)
+{
+ if (string.IsNullOrWhiteSpace(token))
+ throw new ArgumentException("Token cannot be null or empty.", nameof(token));
+
+ var uri = new Uri($"{ChecksPath}/{token}", UriKind.Relative);
+ var result = await GetAsync(uri, cancellationToken).ConfigureAwait(false);
+ return result;
+}
+```
+
+### Nullable Reference Types
+
+- Enable nullable reference types (`enable`)
+- Mark all nullable parameters and properties with `?`
+- Use null-forgiving operator `!` sparingly and only when certain
+- Validate parameters and throw appropriate exceptions
+
+### Async/Await
+
+- All API methods must be async
+- Use `ConfigureAwait(false)` for library code
+- Accept `CancellationToken cancellationToken = default` parameter
+- Name async methods with `Async` suffix
+
+### Error Handling
+
+- Throw specific exception types (ArgumentException, UpdownApiException, etc.)
+- Include helpful error messages
+- Capture and include response content in API exceptions
+- Don't catch exceptions unless you can handle them meaningfully
+
+## Testing Guidelines
+
+### Unit Test Structure
+
+Use the **Arrange-Act-Assert** pattern:
+
+```csharp
+[Test]
+public async Task CheckAsync_ReturnsCheck_WhenSuccessful()
+{
+ // Arrange
+ var mockResult = new Check { Token = "test-token", Url = "https://example.com" };
+
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}/test-token")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithBodyAsJson(mockResult));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act
+ var result = await client.CheckAsync("test-token");
+
+ // Assert
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.Token, Is.EqualTo("test-token"));
+ Assert.That(result.Url, Is.EqualTo("https://example.com"));
+}
+```
+
+### Test Naming
+
+Use descriptive test names that indicate:
+- What is being tested
+- Under what conditions
+- What is the expected outcome
+
+Format: `MethodName_ExpectedBehavior_WhenCondition`
+
+Examples:
+- `CheckAsync_ReturnsCheck_WhenSuccessful`
+- `CheckAsync_ThrowsArgumentException_WhenTokenIsNull`
+- `CheckAsync_ThrowsNotFoundException_WhenCheckDoesNotExist`
+
+### Test Coverage
+
+Aim for:
+- **90%+ code coverage** for critical paths
+- **100% coverage** for public APIs
+- **Error path testing** for all exception scenarios
+- **Edge case testing** (null, empty, boundary values)
+
+### Mocking
+
+Use WireMock.NET for HTTP mocking:
+
+```csharp
+public class MyTests : BaseHttpClientTest
+{
+ [Test]
+ public async Task Example()
+ {
+ // Mock HTTP response
+ Server.Given(Request.Create()
+ .WithPath("/api/endpoint")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithBodyAsJson(new { data = "value" }));
+
+ // Test code...
+ }
+}
+```
+
+## Pull Request Process
+
+### Before Submitting
+
+1. **Update from upstream**:
+```bash
+git fetch upstream
+git rebase upstream/main
+```
+
+2. **Run tests**:
+```bash
+dotnet test
+```
+
+3. **Check for warnings**:
+```bash
+dotnet build /warnaserror
+```
+
+4. **Update documentation** if you've changed:
+ - Public API
+ - Behavior
+ - Configuration
+
+### Commit Messages
+
+Write clear, concise commit messages:
+
+```
+Short summary (50 chars or less)
+
+Longer explanation if needed. Wrap at 72 characters.
+Explain the problem this commit solves and why you
+chose this solution.
+
+Fixes #123
+```
+
+**Commit Message Guidelines**:
+- Use present tense ("Add feature" not "Added feature")
+- Use imperative mood ("Move cursor to..." not "Moves cursor to...")
+- First line should be capitalized
+- No period at the end of the summary line
+- Reference issues and pull requests
+
+### Pull Request Template
+
+When creating a PR, include:
+
+1. **Description**: What does this PR do?
+2. **Motivation**: Why is this change needed?
+3. **Testing**: How was this tested?
+4. **Breaking Changes**: Any breaking changes?
+5. **Checklist**:
+ - [ ] Tests added/updated
+ - [ ] Documentation updated
+ - [ ] No breaking changes (or documented if present)
+ - [ ] All tests passing
+ - [ ] No compiler warnings
+
+### Review Process
+
+1. A maintainer will review your PR
+2. Address any feedback
+3. Once approved, a maintainer will merge
+
+## Release Process
+
+(For maintainers only)
+
+### Versioning
+
+Follow [Semantic Versioning](https://semver.org/):
+- **MAJOR** version for breaking changes
+- **MINOR** version for new features (backward compatible)
+- **PATCH** version for bug fixes
+
+### Release Checklist
+
+1. Update version in `src/UpdownDotnet.csproj`
+2. Update `PackageReleaseNotes`
+3. Update README.md if needed
+4. Create git tag: `git tag v1.x.x`
+5. Push tag: `git push origin v1.x.x`
+6. Build NuGet package: `dotnet pack -c Release`
+7. Push to NuGet.org
+8. Create GitHub release
+
+## Development Tips
+
+### Debugging
+
+Add this to launchSettings.json for easier debugging:
+```json
+{
+ "profiles": {
+ "UpdownDotnet.Tests": {
+ "commandName": "Project",
+ "environmentVariables": {
+ "UPDOWN_API_KEY": "your-test-key"
+ }
+ }
+ }
+}
+```
+
+### IDE Configuration
+
+**Visual Studio**:
+- Enable "Remove unused usings" on save
+- Enable "Format document" on save
+- Install ReSharper (optional but recommended)
+
+**VS Code**:
+- Install C# extension
+- Install C# XML Documentation Comments extension
+- Configure format on save
+
+### Common Tasks
+
+**Add new API endpoint**:
+1. Create model classes in `src/Models/`
+2. Add API methods to appropriate file in `src/Apis/`
+3. Add XML documentation comments
+4. Create tests in `test/UpdownDotNetTests/`
+5. Update README.md with examples
+6. Update API_REFERENCE.md
+
+**Fix a bug**:
+1. Write a failing test that reproduces the bug
+2. Fix the bug
+3. Verify the test passes
+4. Check for similar bugs elsewhere
+5. Submit PR with test and fix
+
+## Questions?
+
+If you have questions:
+1. Check existing documentation
+2. Search closed issues
+3. Open a new issue with the "question" label
+
+## License
+
+By contributing, you agree that your contributions will be licensed under the project's MIT License.
+
+## Thank You!
+
+Your contributions help make this project better for everyone. Thank you for taking the time to contribute!
+
diff --git a/src/Apis/ApiChecks.cs b/src/Apis/ApiChecks.cs
index b96341c..a53c92d 100644
--- a/src/Apis/ApiChecks.cs
+++ b/src/Apis/ApiChecks.cs
@@ -1,49 +1,156 @@
using System;
using System.Collections.Generic;
+using System.Threading;
using System.Threading.Tasks;
using UpdownDotnet.Models;
-// ReSharper disable AsyncApostle.AsyncMethodNamingHighlighting
-
namespace UpdownDotnet
{
public partial class UpdownClient
{
+ ///
+ /// The API path for checks endpoint.
+ ///
public const string ChecksPath = "api/checks";
- public async Task> Checks()
+ ///
+ /// Gets all checks for your account.
+ ///
+ /// Cancellation token.
+ /// A list of all checks.
+ public async Task> ChecksAsync(CancellationToken cancellationToken = default)
{
var uri = new Uri($"{ChecksPath}", UriKind.Relative);
- var result = await GetAsync>(uri).ConfigureAwait(false);
+ var result = await GetAsync>(uri, cancellationToken).ConfigureAwait(false);
return result;
}
- public async Task Check(string token)
+ ///
+ /// Gets a specific check by its token.
+ ///
+ /// The check token.
+ /// Cancellation token.
+ /// The check details.
+ /// Thrown when token is null or empty.
+ public async Task CheckAsync(string token, CancellationToken cancellationToken = default)
{
+ if (string.IsNullOrWhiteSpace(token))
+ throw new ArgumentException("Token cannot be null or empty.", nameof(token));
+
var uri = new Uri($"{ChecksPath}/{token}", UriKind.Relative);
- var result = await GetAsync(uri).ConfigureAwait(false);
+ var result = await GetAsync(uri, cancellationToken).ConfigureAwait(false);
return result;
}
- public async Task CheckCreate(CheckParameters parameters)
+ ///
+ /// Creates a new check with the specified parameters.
+ ///
+ /// The check parameters.
+ /// Cancellation token.
+ /// The created check.
+ /// Thrown when parameters is null.
+ public async Task CheckCreateAsync(CheckParameters parameters, CancellationToken cancellationToken = default)
{
+ if (parameters == null)
+ throw new ArgumentNullException(nameof(parameters));
+
var uri = new Uri($"{ChecksPath}", UriKind.Relative);
- var result = await PostAsync(uri, parameters).ConfigureAwait(false);
+ var result = await PostAsync(uri, parameters, cancellationToken).ConfigureAwait(false);
return result;
}
- public async Task CheckDelete(string token)
+ ///
+ /// Deletes a check by its token.
+ ///
+ /// The check token.
+ /// Cancellation token.
+ /// The delete response.
+ /// Thrown when token is null or empty.
+ public async Task CheckDeleteAsync(string token, CancellationToken cancellationToken = default)
{
+ if (string.IsNullOrWhiteSpace(token))
+ throw new ArgumentException("Token cannot be null or empty.", nameof(token));
+
var uri = new Uri($"{ChecksPath}/{token}", UriKind.Relative);
- var result = await DeleteAsync(uri).ConfigureAwait(false);
+ var result = await DeleteAsync(uri, cancellationToken).ConfigureAwait(false);
return result;
}
- public async Task CheckUpdate(string token, CheckParameters parameters)
+ ///
+ /// Updates an existing check with the specified parameters.
+ ///
+ /// The check token.
+ /// The check parameters to update.
+ /// Cancellation token.
+ /// The updated check.
+ /// Thrown when token is null or empty.
+ /// Thrown when parameters is null.
+ public async Task CheckUpdateAsync(string token, CheckParameters parameters, CancellationToken cancellationToken = default)
{
+ if (string.IsNullOrWhiteSpace(token))
+ throw new ArgumentException("Token cannot be null or empty.", nameof(token));
+ if (parameters == null)
+ throw new ArgumentNullException(nameof(parameters));
+
var uri = new Uri($"{ChecksPath}/{token}", UriKind.Relative);
- var result = await PutAsync(uri, parameters).ConfigureAwait(false);
+ var result = await PutAsync(uri, parameters, cancellationToken).ConfigureAwait(false);
return result;
}
+
+ // Obsolete methods for backward compatibility
+ ///
+ /// Gets all checks for your account.
+ ///
+ /// A list of all checks.
+ [Obsolete("Use ChecksAsync instead.")]
+ public async Task> Checks()
+ {
+ return await ChecksAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Gets a specific check by its token.
+ ///
+ /// The check token.
+ /// The check details.
+ [Obsolete("Use CheckAsync instead.")]
+ public async Task Check(string token)
+ {
+ return await CheckAsync(token).ConfigureAwait(false);
+ }
+
+ ///
+ /// Creates a new check.
+ ///
+ /// The check parameters.
+ /// The created check.
+ [Obsolete("Use CheckCreateAsync instead.")]
+ public async Task CheckCreate(CheckParameters parameters)
+ {
+ return await CheckCreateAsync(parameters).ConfigureAwait(false);
+ }
+
+ ///
+ /// Deletes a check.
+ ///
+ /// The check token.
+ /// The delete response.
+ [Obsolete("Use CheckDeleteAsync instead.")]
+ public async Task CheckDelete(string token)
+ {
+ return await CheckDeleteAsync(token).ConfigureAwait(false);
+ }
+
+ ///
+ /// Updates a check.
+ ///
+ /// The check token.
+ /// The check parameters.
+ /// The updated check.
+ [Obsolete("Use CheckUpdateAsync instead.")]
+ public async Task CheckUpdate(string token, CheckParameters parameters)
+ {
+ return await CheckUpdateAsync(token, parameters).ConfigureAwait(false);
+ }
}
}
diff --git a/src/Apis/ApiDowntimes.cs b/src/Apis/ApiDowntimes.cs
new file mode 100644
index 0000000..85f89ee
--- /dev/null
+++ b/src/Apis/ApiDowntimes.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using UpdownDotnet.Models;
+
+namespace UpdownDotnet
+{
+ public partial class UpdownClient
+ {
+ ///
+ /// Gets the list of downtimes for a specific check.
+ ///
+ /// The check token.
+ /// Optional page number for pagination (starts at 1).
+ /// Cancellation token.
+ /// A list of downtime periods.
+ /// Thrown when token is null or empty.
+ ///
+ /// Returns all downtime periods for the specified check, ordered by most recent first.
+ /// Use pagination to retrieve additional results beyond the default page size.
+ ///
+ public async Task> DowntimesAsync(string token, int? page = null, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ throw new ArgumentException("Token cannot be null or empty.", nameof(token));
+
+ var uriString = page.HasValue
+ ? $"{ChecksPath}/{token}/downtimes?page={page.Value}"
+ : $"{ChecksPath}/{token}/downtimes";
+
+ var uri = new Uri(uriString, UriKind.Relative);
+ var result = await GetAsync>(uri, cancellationToken).ConfigureAwait(false);
+ return result;
+ }
+
+ ///
+ /// Gets the list of downtimes for a specific check.
+ ///
+ /// The check token.
+ /// Optional page number for pagination.
+ /// A list of downtime periods.
+ [Obsolete("Use DowntimesAsync instead.")]
+ public async Task> Downtimes(string token, int? page = null)
+ {
+ return await DowntimesAsync(token, page).ConfigureAwait(false);
+ }
+ }
+}
+
diff --git a/src/Apis/ApiMetrics.cs b/src/Apis/ApiMetrics.cs
new file mode 100644
index 0000000..cf0e339
--- /dev/null
+++ b/src/Apis/ApiMetrics.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using UpdownDotnet.Models;
+
+namespace UpdownDotnet
+{
+ public partial class UpdownClient
+ {
+ ///
+ /// Gets performance metrics for a specific check.
+ ///
+ /// The check token.
+ /// Optional start time for metrics (ISO 8601 format or UNIX timestamp).
+ /// Optional end time for metrics (ISO 8601 format or UNIX timestamp).
+ /// Optional grouping interval (e.g., "time" or "host"). Default is "time".
+ /// Cancellation token.
+ /// A list of metric data points.
+ /// Thrown when token is null or empty.
+ ///
+ /// Returns performance metrics including response times, Apdex scores, and request statistics.
+ /// Metrics are aggregated over the specified time period.
+ ///
+ public async Task> MetricsAsync(
+ string token,
+ string? from = null,
+ string? to = null,
+ string? group = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ throw new ArgumentException("Token cannot be null or empty.", nameof(token));
+
+ var queryParams = new List();
+ if (!string.IsNullOrEmpty(from))
+ queryParams.Add($"from={Uri.EscapeDataString(from)}");
+ if (!string.IsNullOrEmpty(to))
+ queryParams.Add($"to={Uri.EscapeDataString(to)}");
+ if (!string.IsNullOrEmpty(group))
+ queryParams.Add($"group={Uri.EscapeDataString(group)}");
+
+ var uriString = queryParams.Count > 0
+ ? $"{ChecksPath}/{token}/metrics?{string.Join("&", queryParams)}"
+ : $"{ChecksPath}/{token}/metrics";
+
+ var uri = new Uri(uriString, UriKind.Relative);
+ var result = await GetAsync>(uri, cancellationToken).ConfigureAwait(false);
+ return result;
+ }
+
+ ///
+ /// Gets performance metrics for a specific check.
+ ///
+ /// The check token.
+ /// Optional start time.
+ /// Optional end time.
+ /// Optional grouping interval.
+ /// A list of metric data points.
+ [Obsolete("Use MetricsAsync instead.")]
+ public async Task> Metrics(
+ string token,
+ string? from = null,
+ string? to = null,
+ string? group = null)
+ {
+ return await MetricsAsync(token, from, to, group).ConfigureAwait(false);
+ }
+ }
+}
+
diff --git a/src/Apis/ApiNodes.cs b/src/Apis/ApiNodes.cs
new file mode 100644
index 0000000..9275ea2
--- /dev/null
+++ b/src/Apis/ApiNodes.cs
@@ -0,0 +1,93 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using UpdownDotnet.Models;
+
+namespace UpdownDotnet
+{
+ public partial class UpdownClient
+ {
+ ///
+ /// The API path for nodes endpoint.
+ ///
+ public const string NodesPath = "api/nodes";
+
+ ///
+ /// Gets all monitoring nodes/locations available in Updown.io.
+ ///
+ /// Cancellation token.
+ /// A list of all monitoring nodes.
+ ///
+ /// Returns information about all monitoring locations including their geographic
+ /// coordinates, IP addresses, and location details.
+ ///
+ public async Task> NodesAsync(CancellationToken cancellationToken = default)
+ {
+ var uri = new System.Uri($"{NodesPath}", System.UriKind.Relative);
+ var result = await GetAsync>(uri, cancellationToken).ConfigureAwait(false);
+ return result;
+ }
+
+ ///
+ /// Gets all IPv4 addresses used by Updown.io monitoring nodes.
+ ///
+ /// Cancellation token.
+ /// A collection of IPv4 addresses.
+ ///
+ /// Useful for firewall whitelisting to allow Updown.io monitoring requests.
+ ///
+ public async Task NodesIpv4Async(CancellationToken cancellationToken = default)
+ {
+ var uri = new System.Uri($"{NodesPath}/ipv4", System.UriKind.Relative);
+ var result = await GetAsync(uri, cancellationToken).ConfigureAwait(false);
+ return result;
+ }
+
+ ///
+ /// Gets all IPv6 addresses used by Updown.io monitoring nodes.
+ ///
+ /// Cancellation token.
+ /// A collection of IPv6 addresses.
+ ///
+ /// Useful for firewall whitelisting to allow Updown.io monitoring requests over IPv6.
+ ///
+ public async Task NodesIpv6Async(CancellationToken cancellationToken = default)
+ {
+ var uri = new System.Uri($"{NodesPath}/ipv6", System.UriKind.Relative);
+ var result = await GetAsync(uri, cancellationToken).ConfigureAwait(false);
+ return result;
+ }
+
+ // Obsolete methods for backward compatibility
+ ///
+ /// Gets all monitoring nodes.
+ ///
+ /// A list of all monitoring nodes.
+ [System.Obsolete("Use NodesAsync instead.")]
+ public async Task> Nodes()
+ {
+ return await NodesAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Gets all IPv4 addresses.
+ ///
+ /// A collection of IPv4 addresses.
+ [System.Obsolete("Use NodesIpv4Async instead.")]
+ public async Task NodesIpv4()
+ {
+ return await NodesIpv4Async().ConfigureAwait(false);
+ }
+
+ ///
+ /// Gets all IPv6 addresses.
+ ///
+ /// A collection of IPv6 addresses.
+ [System.Obsolete("Use NodesIpv6Async instead.")]
+ public async Task NodesIpv6()
+ {
+ return await NodesIpv6Async().ConfigureAwait(false);
+ }
+ }
+}
+
diff --git a/src/Apis/ApiPulse.cs b/src/Apis/ApiPulse.cs
index fe8fe09..54f60c7 100644
--- a/src/Apis/ApiPulse.cs
+++ b/src/Apis/ApiPulse.cs
@@ -1,9 +1,8 @@
using System;
using System.Net.Http;
+using System.Threading;
using System.Threading.Tasks;
-// ReSharper disable AsyncApostle.AsyncMethodNamingHighlighting
-
namespace UpdownDotnet
{
public partial class UpdownClient
@@ -13,15 +12,15 @@ public partial class UpdownClient
/// This is used for cron job and background task monitoring.
///
/// The complete pulse URL provided by Updown.io (e.g., "https://pulse.updown.io/token/key")
- /// A task representing the asynchronous operation
- /// Thrown when pulseUrl is null or empty
- /// Thrown when the HTTP request fails
- public async Task SendPulse(string pulseUrl)
+ /// Cancellation token.
+ /// A task representing the asynchronous operation.
+ /// Thrown when pulseUrl is null or empty.
+ public async Task SendPulseAsync(string pulseUrl, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(pulseUrl))
- throw new ArgumentNullException(nameof(pulseUrl), "Pulse URL cannot be null or empty");
+ throw new ArgumentException("Pulse URL cannot be null or empty.", nameof(pulseUrl));
- await HttpGetAsync(pulseUrl).ConfigureAwait(false);
+ await HttpGetAsync(pulseUrl, cancellationToken).ConfigureAwait(false);
}
///
@@ -29,16 +28,40 @@ public async Task SendPulse(string pulseUrl)
/// This is used for cron job and background task monitoring.
///
/// The complete pulse URL provided by Updown.io (e.g., "https://pulse.updown.io/token/key")
- /// Optional content to send with the POST request
- /// A task representing the asynchronous operation
- /// Thrown when pulseUrl is null or empty
- /// Thrown when the HTTP request fails
- public async Task SendPulsePost(string pulseUrl, HttpContent content = null)
+ /// Optional content to send with the POST request.
+ /// Cancellation token.
+ /// A task representing the asynchronous operation.
+ /// Thrown when pulseUrl is null or empty.
+ public async Task SendPulsePostAsync(string pulseUrl, HttpContent? content = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(pulseUrl))
- throw new ArgumentNullException(nameof(pulseUrl), "Pulse URL cannot be null or empty");
+ throw new ArgumentException("Pulse URL cannot be null or empty.", nameof(pulseUrl));
+
+ await HttpPostAsync(pulseUrl, content, cancellationToken).ConfigureAwait(false);
+ }
- await HttpPostAsync(pulseUrl, content).ConfigureAwait(false);
+ // Obsolete methods for backward compatibility
+ ///
+ /// Sends a pulse heartbeat using GET request.
+ ///
+ /// The pulse URL.
+ /// A task representing the asynchronous operation.
+ [Obsolete("Use SendPulseAsync instead.")]
+ public async Task SendPulse(string pulseUrl)
+ {
+ await SendPulseAsync(pulseUrl).ConfigureAwait(false);
+ }
+
+ ///
+ /// Sends a pulse heartbeat using POST request.
+ ///
+ /// The pulse URL.
+ /// Optional content.
+ /// A task representing the asynchronous operation.
+ [Obsolete("Use SendPulsePostAsync instead.")]
+ public async Task SendPulsePost(string pulseUrl, HttpContent? content = null)
+ {
+ await SendPulsePostAsync(pulseUrl, content).ConfigureAwait(false);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Apis/ApiRecipients.cs b/src/Apis/ApiRecipients.cs
index 7714415..39d9c7f 100644
--- a/src/Apis/ApiRecipients.cs
+++ b/src/Apis/ApiRecipients.cs
@@ -1,35 +1,95 @@
using System;
using System.Collections.Generic;
+using System.Threading;
using System.Threading.Tasks;
using UpdownDotnet.Models;
-// ReSharper disable AsyncApostle.AsyncMethodNamingHighlighting
-
namespace UpdownDotnet
{
public partial class UpdownClient
{
+ ///
+ /// The API path for recipients endpoint.
+ ///
public const string RecipientPath = "api/recipients";
- public async Task> Recipients()
+ ///
+ /// Gets all notification recipients for your account.
+ ///
+ /// Cancellation token.
+ /// A list of all recipients.
+ public async Task> RecipientsAsync(CancellationToken cancellationToken = default)
{
var uri = new Uri($"{RecipientPath}", UriKind.Relative);
- var result = await GetAsync>(uri).ConfigureAwait(false);
+ var result = await GetAsync>(uri, cancellationToken).ConfigureAwait(false);
return result;
}
- public async Task RecipientCreate(RecipientParameters parameters)
+ ///
+ /// Creates a new notification recipient.
+ ///
+ /// The recipient parameters.
+ /// Cancellation token.
+ /// The created recipient.
+ /// Thrown when parameters is null.
+ public async Task RecipientCreateAsync(RecipientParameters parameters, CancellationToken cancellationToken = default)
{
+ if (parameters == null)
+ throw new ArgumentNullException(nameof(parameters));
+
var uri = new Uri($"{RecipientPath}", UriKind.Relative);
- var result = await PostAsync(uri, parameters).ConfigureAwait(false);
+ var result = await PostAsync(uri, parameters, cancellationToken).ConfigureAwait(false);
return result;
}
- public async Task RecipientDelete(string token)
+ ///
+ /// Deletes a notification recipient by its token.
+ ///
+ /// The recipient token.
+ /// Cancellation token.
+ /// The delete response.
+ /// Thrown when token is null or empty.
+ public async Task RecipientDeleteAsync(string token, CancellationToken cancellationToken = default)
{
+ if (string.IsNullOrWhiteSpace(token))
+ throw new ArgumentException("Token cannot be null or empty.", nameof(token));
+
var uri = new Uri($"{RecipientPath}/{token}", UriKind.Relative);
- var result = await DeleteAsync(uri).ConfigureAwait(false);
+ var result = await DeleteAsync(uri, cancellationToken).ConfigureAwait(false);
return result;
}
+
+ // Obsolete methods for backward compatibility
+ ///
+ /// Gets all notification recipients.
+ ///
+ /// A list of all recipients.
+ [Obsolete("Use RecipientsAsync instead.")]
+ public async Task> Recipients()
+ {
+ return await RecipientsAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Creates a new recipient.
+ ///
+ /// The recipient parameters.
+ /// The created recipient.
+ [Obsolete("Use RecipientCreateAsync instead.")]
+ public async Task RecipientCreate(RecipientParameters parameters)
+ {
+ return await RecipientCreateAsync(parameters).ConfigureAwait(false);
+ }
+
+ ///
+ /// Deletes a recipient.
+ ///
+ /// The recipient token.
+ /// The delete response.
+ [Obsolete("Use RecipientDeleteAsync instead.")]
+ public async Task RecipientDelete(string token)
+ {
+ return await RecipientDeleteAsync(token).ConfigureAwait(false);
+ }
}
}
diff --git a/src/Apis/ApiStatusPages.cs b/src/Apis/ApiStatusPages.cs
index d1dfaef..9365ff1 100644
--- a/src/Apis/ApiStatusPages.cs
+++ b/src/Apis/ApiStatusPages.cs
@@ -1,42 +1,128 @@
using System;
using System.Collections.Generic;
+using System.Threading;
using System.Threading.Tasks;
using UpdownDotnet.Models;
-// ReSharper disable AsyncApostle.AsyncMethodNamingHighlighting
-
namespace UpdownDotnet
{
public partial class UpdownClient
{
+ ///
+ /// The API path for status pages endpoint.
+ ///
public const string StatusPagesPath = "api/status_pages";
- public async Task> StatusPages()
+ ///
+ /// Gets all status pages for your account.
+ ///
+ /// Cancellation token.
+ /// A list of all status pages.
+ public async Task> StatusPagesAsync(CancellationToken cancellationToken = default)
{
var uri = new Uri($"{StatusPagesPath}", UriKind.Relative);
- var result = await GetAsync>(uri).ConfigureAwait(false);
+ var result = await GetAsync>(uri, cancellationToken).ConfigureAwait(false);
return result;
}
- public async Task StatusPageCreate(StatusPageParameters parameters)
+ ///
+ /// Creates a new status page with the specified parameters.
+ ///
+ /// The status page parameters.
+ /// Cancellation token.
+ /// The created status page.
+ /// Thrown when parameters is null.
+ public async Task StatusPageCreateAsync(StatusPageParameters parameters, CancellationToken cancellationToken = default)
{
+ if (parameters == null)
+ throw new ArgumentNullException(nameof(parameters));
+
var uri = new Uri($"{StatusPagesPath}", UriKind.Relative);
- var result = await PostAsync(uri, parameters).ConfigureAwait(false);
+ var result = await PostAsync(uri, parameters, cancellationToken).ConfigureAwait(false);
return result;
}
- public async Task StatusPageDelete(string token)
+ ///
+ /// Deletes a status page by its token.
+ ///
+ /// The status page token.
+ /// Cancellation token.
+ /// The delete response.
+ /// Thrown when token is null or empty.
+ public async Task StatusPageDeleteAsync(string token, CancellationToken cancellationToken = default)
{
+ if (string.IsNullOrWhiteSpace(token))
+ throw new ArgumentException("Token cannot be null or empty.", nameof(token));
+
var uri = new Uri($"{StatusPagesPath}/{token}", UriKind.Relative);
- var result = await DeleteAsync(uri).ConfigureAwait(false);
+ var result = await DeleteAsync(uri, cancellationToken).ConfigureAwait(false);
return result;
}
- public async Task StatusPageUpdate(string token, StatusPageParameters parameters)
+ ///
+ /// Updates an existing status page with the specified parameters.
+ ///
+ /// The status page token.
+ /// The status page parameters to update.
+ /// Cancellation token.
+ /// The updated status page.
+ /// Thrown when token is null or empty.
+ /// Thrown when parameters is null.
+ public async Task StatusPageUpdateAsync(string token, StatusPageParameters parameters, CancellationToken cancellationToken = default)
{
+ if (string.IsNullOrWhiteSpace(token))
+ throw new ArgumentException("Token cannot be null or empty.", nameof(token));
+ if (parameters == null)
+ throw new ArgumentNullException(nameof(parameters));
+
var uri = new Uri($"{StatusPagesPath}/{token}", UriKind.Relative);
- var result = await PutAsync(uri, parameters).ConfigureAwait(false);
+ var result = await PutAsync(uri, parameters, cancellationToken).ConfigureAwait(false);
return result;
}
+
+ // Obsolete methods for backward compatibility
+ ///
+ /// Gets all status pages.
+ ///
+ /// A list of all status pages.
+ [Obsolete("Use StatusPagesAsync instead.")]
+ public async Task> StatusPages()
+ {
+ return await StatusPagesAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Creates a new status page.
+ ///
+ /// The status page parameters.
+ /// The created status page.
+ [Obsolete("Use StatusPageCreateAsync instead.")]
+ public async Task StatusPageCreate(StatusPageParameters parameters)
+ {
+ return await StatusPageCreateAsync(parameters).ConfigureAwait(false);
+ }
+
+ ///
+ /// Deletes a status page.
+ ///
+ /// The status page token.
+ /// The delete response.
+ [Obsolete("Use StatusPageDeleteAsync instead.")]
+ public async Task StatusPageDelete(string token)
+ {
+ return await StatusPageDeleteAsync(token).ConfigureAwait(false);
+ }
+
+ ///
+ /// Updates a status page.
+ ///
+ /// The status page token.
+ /// The status page parameters.
+ /// The updated status page.
+ [Obsolete("Use StatusPageUpdateAsync instead.")]
+ public async Task StatusPageUpdate(string token, StatusPageParameters parameters)
+ {
+ return await StatusPageUpdateAsync(token, parameters).ConfigureAwait(false);
+ }
}
}
diff --git a/src/Exceptions/UpdownApiException.cs b/src/Exceptions/UpdownApiException.cs
new file mode 100644
index 0000000..256f9fb
--- /dev/null
+++ b/src/Exceptions/UpdownApiException.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Net;
+
+namespace UpdownDotnet.Exceptions
+{
+ ///
+ /// Base exception for all Updown.io API errors.
+ ///
+ public class UpdownApiException : Exception
+ {
+ ///
+ /// Gets the HTTP status code associated with this error.
+ ///
+ public HttpStatusCode StatusCode { get; }
+
+ ///
+ /// Gets the raw response content from the API.
+ ///
+ public string? ResponseContent { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The error message.
+ /// The HTTP status code.
+ /// The raw response content.
+ /// The inner exception.
+ public UpdownApiException(
+ string message,
+ HttpStatusCode statusCode,
+ string? responseContent = null,
+ Exception? innerException = null)
+ : base(message, innerException)
+ {
+ StatusCode = statusCode;
+ ResponseContent = responseContent;
+ }
+ }
+}
+
diff --git a/src/Exceptions/UpdownBadRequestException.cs b/src/Exceptions/UpdownBadRequestException.cs
new file mode 100644
index 0000000..1091ed4
--- /dev/null
+++ b/src/Exceptions/UpdownBadRequestException.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Net;
+
+namespace UpdownDotnet.Exceptions
+{
+ ///
+ /// Exception thrown when the API request is invalid (400).
+ ///
+ public class UpdownBadRequestException : UpdownApiException
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The error message.
+ /// The raw response content.
+ /// The inner exception.
+ public UpdownBadRequestException(
+ string message,
+ string? responseContent = null,
+ Exception? innerException = null)
+ : base(message, HttpStatusCode.BadRequest, responseContent, innerException)
+ {
+ }
+ }
+}
+
diff --git a/src/Exceptions/UpdownNotFoundException.cs b/src/Exceptions/UpdownNotFoundException.cs
new file mode 100644
index 0000000..4cc0af8
--- /dev/null
+++ b/src/Exceptions/UpdownNotFoundException.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Net;
+
+namespace UpdownDotnet.Exceptions
+{
+ ///
+ /// Exception thrown when a requested resource is not found (404).
+ ///
+ public class UpdownNotFoundException : UpdownApiException
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The error message.
+ /// The raw response content.
+ /// The inner exception.
+ public UpdownNotFoundException(
+ string message,
+ string? responseContent = null,
+ Exception? innerException = null)
+ : base(message, HttpStatusCode.NotFound, responseContent, innerException)
+ {
+ }
+ }
+}
+
diff --git a/src/Exceptions/UpdownRateLimitException.cs b/src/Exceptions/UpdownRateLimitException.cs
new file mode 100644
index 0000000..f59e266
--- /dev/null
+++ b/src/Exceptions/UpdownRateLimitException.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Net;
+
+namespace UpdownDotnet.Exceptions
+{
+ ///
+ /// Exception thrown when API rate limit is exceeded (429).
+ ///
+ public class UpdownRateLimitException : UpdownApiException
+ {
+ ///
+ /// Gets the time when the rate limit will reset, if available.
+ ///
+ public DateTimeOffset? ResetTime { get; }
+
+ ///
+ /// Gets the number of seconds until the rate limit resets, if available.
+ ///
+ public int? RetryAfterSeconds { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The error message.
+ /// The number of seconds until the rate limit resets.
+ /// The raw response content.
+ /// The inner exception.
+ public UpdownRateLimitException(
+ string message,
+ int? retryAfterSeconds = null,
+ string? responseContent = null,
+ Exception? innerException = null)
+ : base(message, (HttpStatusCode)429, responseContent, innerException)
+ {
+ RetryAfterSeconds = retryAfterSeconds;
+ if (retryAfterSeconds.HasValue)
+ {
+ ResetTime = DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds.Value);
+ }
+ }
+ }
+}
+
diff --git a/src/Exceptions/UpdownUnauthorizedException.cs b/src/Exceptions/UpdownUnauthorizedException.cs
new file mode 100644
index 0000000..a0dfae5
--- /dev/null
+++ b/src/Exceptions/UpdownUnauthorizedException.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Net;
+
+namespace UpdownDotnet.Exceptions
+{
+ ///
+ /// Exception thrown when authentication fails (401 or 403).
+ ///
+ public class UpdownUnauthorizedException : UpdownApiException
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The error message.
+ /// The HTTP status code (401 or 403).
+ /// The raw response content.
+ /// The inner exception.
+ public UpdownUnauthorizedException(
+ string message,
+ HttpStatusCode statusCode = HttpStatusCode.Unauthorized,
+ string? responseContent = null,
+ Exception? innerException = null)
+ : base(message, statusCode, responseContent, innerException)
+ {
+ }
+ }
+}
+
diff --git a/src/Models/Check.cs b/src/Models/Check.cs
index 0637ec3..d46438b 100644
--- a/src/Models/Check.cs
+++ b/src/Models/Check.cs
@@ -4,124 +4,580 @@
namespace UpdownDotnet.Models
{
+ ///
+ /// Represents an Updown.io monitoring check.
+ ///
public class Check
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
public Check() { }
+ ///
+ /// Initializes a new instance of the class from parameters.
+ ///
+ /// The check parameters.
public Check(CheckParameters parameters)
{
Url = parameters.Url;
Alias = parameters.Alias;
Down = parameters.Down;
Period = parameters.Period;
- Apdex_T = parameters.Apdex_T;
- String_Match = parameters.String_Match;
+ ApdexT = parameters.ApdexT;
+ StringMatch = parameters.StringMatch;
Enabled = parameters.Enabled;
Published = parameters.Published;
- Disabled_Locations = parameters.Disabled_Locations;
+ DisabledLocations = parameters.DisabledLocations;
Recipients = parameters.Recipients;
- Mute_Until = parameters.Mute_Until;
- FavIcon_Url = parameters.FavIcon_Url;
- Custom_Headers = parameters.Custom_Headers;
- Http_Verb = parameters.Http_Verb;
- Http_Body = parameters.Http_Body;
+ MuteUntil = parameters.MuteUntil;
+ FavIconUrl = parameters.FavIconUrl;
+ CustomHeaders = parameters.CustomHeaders;
+ HttpVerb = parameters.HttpVerb;
+ HttpBody = parameters.HttpBody;
}
+ ///
+ /// Gets or sets the unique token identifying this check.
+ ///
[JsonPropertyName("token")]
- public string Token { get; set; }
+ public string? Token { get; set; }
+
+ ///
+ /// Gets or sets the URL being monitored.
+ ///
[JsonPropertyName("url")]
- public string Url { get; set; }
+ public string? Url { get; set; }
+
+ ///
+ /// Gets or sets the alias/name for this check.
+ ///
[JsonPropertyName("alias")]
- public string Alias { get; set; }
+ public string? Alias { get; set; }
+
+ ///
+ /// Gets or sets the last HTTP status code received.
+ ///
[JsonPropertyName("last_status")]
- public double? Last_Status { get; set; }
+ public double? LastStatus { get; set; }
+
+ ///
+ /// Gets or sets the uptime percentage.
+ ///
[JsonPropertyName("uptime")]
public double? Uptime { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the check is currently down.
+ ///
[JsonPropertyName("down")]
public bool? Down { get; set; }
+
+ ///
+ /// Gets or sets the time when the check went down.
+ ///
[JsonPropertyName("down_since")]
- public DateTimeOffset? Down_Since { get; set; }
+ public DateTimeOffset? DownSince { get; set; }
+
+ ///
+ /// Gets or sets the time when the check came back up.
+ ///
[JsonPropertyName("up_since")]
- public DateTimeOffset? Up_Since { get; set; }
+ public DateTimeOffset? UpSince { get; set; }
+
+ ///
+ /// Gets or sets the last error message, if any.
+ ///
[JsonPropertyName("error")]
- public string Error { get; set; }
+ public string? Error { get; set; }
+
+ ///
+ /// Gets or sets the check period in seconds.
+ ///
[JsonPropertyName("period")]
public double? Period { get; set; }
+
+ ///
+ /// Gets or sets the Apdex threshold in seconds for performance monitoring.
+ ///
[JsonPropertyName("apdex_t")]
- public double? Apdex_T { get; set; }
+ public double? ApdexT { get; set; }
+
+ ///
+ /// Gets or sets the string to match in the response body.
+ ///
[JsonPropertyName("string_match")]
- public string String_Match { get; set; }
+ public string? StringMatch { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this check is enabled.
+ ///
[JsonPropertyName("enabled")]
public bool? Enabled { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this check is published on status page.
+ ///
[JsonPropertyName("published")]
public bool? Published { get; set; }
+
+ ///
+ /// Gets or sets the list of disabled monitoring locations.
+ ///
[JsonPropertyName("disabled_locations")]
- public List Disabled_Locations { get; set; }
+ public List? DisabledLocations { get; set; }
+
+ ///
+ /// Gets or sets the list of recipient IDs to notify.
+ ///
[JsonPropertyName("recipients")]
- public List Recipients { get; set; }
+ public List? Recipients { get; set; }
+
+ ///
+ /// Gets or sets the time of the last check.
+ ///
[JsonPropertyName("last_check_at")]
- public DateTimeOffset? Last_Check_At { get; set; }
+ public DateTimeOffset? LastCheckAt { get; set; }
+
+ ///
+ /// Gets or sets the time of the next scheduled check.
+ ///
[JsonPropertyName("next_check_at")]
- public DateTimeOffset? Next_Check_At { get; set; }
+ public DateTimeOffset? NextCheckAt { get; set; }
+
+ ///
+ /// Gets or sets the time when this check was created.
+ ///
[JsonPropertyName("created_at")]
- public DateTimeOffset? Created_At { get; set; }
+ public DateTimeOffset? CreatedAt { get; set; }
+
+ ///
+ /// Gets or sets the mute until expression (e.g., "forever", specific time).
+ ///
[JsonPropertyName("mute_until")]
- public string Mute_Until { get; set; }
+ public string? MuteUntil { get; set; }
+
+ ///
+ /// Gets or sets the favicon URL for this check.
+ ///
[JsonPropertyName("favicon_url")]
- public string FavIcon_Url { get; set; }
+ public string? FavIconUrl { get; set; }
+
+ ///
+ /// Gets or sets the custom HTTP headers to send with requests.
+ ///
[JsonPropertyName("custom_headers")]
- public Dictionary Custom_Headers { get; set; }
+ public Dictionary? CustomHeaders { get; set; }
+
+ ///
+ /// Gets or sets the HTTP verb/method to use (GET, POST, etc.).
+ ///
[JsonPropertyName("http_verb")]
- public string Http_Verb { get; set; }
+ public string? HttpVerb { get; set; }
+
+ ///
+ /// Gets or sets the HTTP request body to send.
+ ///
[JsonPropertyName("http_body")]
- public string Http_Body { get; set; }
+ public string? HttpBody { get; set; }
+
+ ///
+ /// Gets or sets the SSL certificate information.
+ ///
[JsonPropertyName("ssl")]
- public Ssl Ssl { get; set; }
+ public Ssl? Ssl { get; set; }
+
+ // Obsolete properties for backward compatibility
+ ///
+ /// Gets or sets the last HTTP status code received.
+ ///
+ [Obsolete("Use LastStatus instead.")]
+ [JsonIgnore]
+ public double? Last_Status
+ {
+ get => LastStatus;
+ set => LastStatus = value;
+ }
+
+ ///
+ /// Gets or sets the time when the check went down.
+ ///
+ [Obsolete("Use DownSince instead.")]
+ [JsonIgnore]
+ public DateTimeOffset? Down_Since
+ {
+ get => DownSince;
+ set => DownSince = value;
+ }
+
+ ///
+ /// Gets or sets the time when the check came back up.
+ ///
+ [Obsolete("Use UpSince instead.")]
+ [JsonIgnore]
+ public DateTimeOffset? Up_Since
+ {
+ get => UpSince;
+ set => UpSince = value;
+ }
+
+ ///
+ /// Gets or sets the Apdex threshold.
+ ///
+ [Obsolete("Use ApdexT instead.")]
+ [JsonIgnore]
+ public double? Apdex_T
+ {
+ get => ApdexT;
+ set => ApdexT = value;
+ }
+
+ ///
+ /// Gets or sets the string to match.
+ ///
+ [Obsolete("Use StringMatch instead.")]
+ [JsonIgnore]
+ public string? String_Match
+ {
+ get => StringMatch;
+ set => StringMatch = value;
+ }
+
+ ///
+ /// Gets or sets the disabled monitoring locations.
+ ///
+ [Obsolete("Use DisabledLocations instead.")]
+ [JsonIgnore]
+ public List? Disabled_Locations
+ {
+ get => DisabledLocations;
+ set => DisabledLocations = value;
+ }
+
+ ///
+ /// Gets or sets the time of the last check.
+ ///
+ [Obsolete("Use LastCheckAt instead.")]
+ [JsonIgnore]
+ public DateTimeOffset? Last_Check_At
+ {
+ get => LastCheckAt;
+ set => LastCheckAt = value;
+ }
+
+ ///
+ /// Gets or sets the time of the next check.
+ ///
+ [Obsolete("Use NextCheckAt instead.")]
+ [JsonIgnore]
+ public DateTimeOffset? Next_Check_At
+ {
+ get => NextCheckAt;
+ set => NextCheckAt = value;
+ }
+
+ ///
+ /// Gets or sets the creation time.
+ ///
+ [Obsolete("Use CreatedAt instead.")]
+ [JsonIgnore]
+ public DateTimeOffset? Created_At
+ {
+ get => CreatedAt;
+ set => CreatedAt = value;
+ }
+
+ ///
+ /// Gets or sets the mute until value.
+ ///
+ [Obsolete("Use MuteUntil instead.")]
+ [JsonIgnore]
+ public string? Mute_Until
+ {
+ get => MuteUntil;
+ set => MuteUntil = value;
+ }
+
+ ///
+ /// Gets or sets the favicon URL.
+ ///
+ [Obsolete("Use FavIconUrl instead.")]
+ [JsonIgnore]
+ public string? FavIcon_Url
+ {
+ get => FavIconUrl;
+ set => FavIconUrl = value;
+ }
+
+ ///
+ /// Gets or sets the custom headers.
+ ///
+ [Obsolete("Use CustomHeaders instead.")]
+ [JsonIgnore]
+ public Dictionary? Custom_Headers
+ {
+ get => CustomHeaders;
+ set => CustomHeaders = value;
+ }
+
+ ///
+ /// Gets or sets the HTTP verb.
+ ///
+ [Obsolete("Use HttpVerb instead.")]
+ [JsonIgnore]
+ public string? Http_Verb
+ {
+ get => HttpVerb;
+ set => HttpVerb = value;
+ }
+
+ ///
+ /// Gets or sets the HTTP body.
+ ///
+ [Obsolete("Use HttpBody instead.")]
+ [JsonIgnore]
+ public string? Http_Body
+ {
+ get => HttpBody;
+ set => HttpBody = value;
+ }
}
+ ///
+ /// Represents SSL certificate information for a check.
+ ///
public class Ssl
{
+ ///
+ /// Gets or sets the time when the SSL certificate was last tested.
+ ///
[JsonPropertyName("tested_at")]
- public DateTimeOffset? Tested_At { get; set; }
+ public DateTimeOffset? TestedAt { get; set; }
+
+ ///
+ /// Gets or sets the time when the SSL certificate expires.
+ ///
[JsonPropertyName("expires_at")]
- public DateTimeOffset? Expires_At { get; set; }
+ public DateTimeOffset? ExpiresAt { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the SSL certificate is valid.
+ ///
[JsonPropertyName("valid")]
public bool? Valid { get; set; }
+
+ ///
+ /// Gets or sets the SSL certificate error message, if any.
+ ///
[JsonPropertyName("error")]
- public string Error { get; set; }
+ public string? Error { get; set; }
+
+ // Obsolete properties
+ ///
+ /// Gets or sets the tested at time.
+ ///
+ [Obsolete("Use TestedAt instead.")]
+ [JsonIgnore]
+ public DateTimeOffset? Tested_At
+ {
+ get => TestedAt;
+ set => TestedAt = value;
+ }
+
+ ///
+ /// Gets or sets the expires at time.
+ ///
+ [Obsolete("Use ExpiresAt instead.")]
+ [JsonIgnore]
+ public DateTimeOffset? Expires_At
+ {
+ get => ExpiresAt;
+ set => ExpiresAt = value;
+ }
}
+ ///
+ /// Parameters for creating or updating a check.
+ ///
public class CheckParameters
{
+ ///
+ /// Gets or sets the URL to monitor.
+ ///
[JsonPropertyName("url")]
- public string Url { get; set; }
+ public string? Url { get; set; }
+
+ ///
+ /// Gets or sets the alias/name for the check.
+ ///
[JsonPropertyName("alias")]
- public string Alias { get; set; }
+ public string? Alias { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the check is down.
+ ///
[JsonPropertyName("down")]
public bool? Down { get; set; }
+
+ ///
+ /// Gets or sets the check period in seconds.
+ ///
[JsonPropertyName("period")]
public double? Period { get; set; }
+
+ ///
+ /// Gets or sets the Apdex threshold in seconds.
+ ///
[JsonPropertyName("apdex_t")]
- public double? Apdex_T { get; set; }
+ public double? ApdexT { get; set; }
+
+ ///
+ /// Gets or sets the string to match in the response.
+ ///
[JsonPropertyName("string_match")]
- public string String_Match { get; set; }
+ public string? StringMatch { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the check is enabled.
+ ///
[JsonPropertyName("enabled")]
public bool? Enabled { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the check is published.
+ ///
[JsonPropertyName("published")]
public bool? Published { get; set; }
+
+ ///
+ /// Gets or sets the disabled monitoring locations.
+ ///
[JsonPropertyName("disabled_locations")]
- public List Disabled_Locations { get; set; }
+ public List? DisabledLocations { get; set; }
+
+ ///
+ /// Gets or sets the recipient IDs.
+ ///
[JsonPropertyName("recipients")]
- public List Recipients { get; set; }
+ public List? Recipients { get; set; }
+
+ ///
+ /// Gets or sets the mute until value.
+ ///
[JsonPropertyName("mute_until")]
- public string Mute_Until { get; set; }
+ public string? MuteUntil { get; set; }
+
+ ///
+ /// Gets or sets the favicon URL.
+ ///
[JsonPropertyName("favicon_url")]
- public string FavIcon_Url { get; set; }
+ public string? FavIconUrl { get; set; }
+
+ ///
+ /// Gets or sets the custom HTTP headers.
+ ///
[JsonPropertyName("custom_headers")]
- public Dictionary Custom_Headers { get; set; }
+ public Dictionary? CustomHeaders { get; set; }
+
+ ///
+ /// Gets or sets the HTTP verb/method.
+ ///
[JsonPropertyName("http_verb")]
- public string Http_Verb { get; set; }
+ public string? HttpVerb { get; set; }
+
+ ///
+ /// Gets or sets the HTTP request body.
+ ///
[JsonPropertyName("http_body")]
- public string Http_Body { get; set; }
+ public string? HttpBody { get; set; }
+
+ // Obsolete properties
+ ///
+ /// Gets or sets the Apdex threshold.
+ ///
+ [Obsolete("Use ApdexT instead.")]
+ [JsonIgnore]
+ public double? Apdex_T
+ {
+ get => ApdexT;
+ set => ApdexT = value;
+ }
+
+ ///
+ /// Gets or sets the string match value.
+ ///
+ [Obsolete("Use StringMatch instead.")]
+ [JsonIgnore]
+ public string? String_Match
+ {
+ get => StringMatch;
+ set => StringMatch = value;
+ }
+
+ ///
+ /// Gets or sets the disabled locations.
+ ///
+ [Obsolete("Use DisabledLocations instead.")]
+ [JsonIgnore]
+ public List? Disabled_Locations
+ {
+ get => DisabledLocations;
+ set => DisabledLocations = value;
+ }
+
+ ///
+ /// Gets or sets the mute until value.
+ ///
+ [Obsolete("Use MuteUntil instead.")]
+ [JsonIgnore]
+ public string? Mute_Until
+ {
+ get => MuteUntil;
+ set => MuteUntil = value;
+ }
+
+ ///
+ /// Gets or sets the favicon URL.
+ ///
+ [Obsolete("Use FavIconUrl instead.")]
+ [JsonIgnore]
+ public string? FavIcon_Url
+ {
+ get => FavIconUrl;
+ set => FavIconUrl = value;
+ }
+
+ ///
+ /// Gets or sets the custom headers.
+ ///
+ [Obsolete("Use CustomHeaders instead.")]
+ [JsonIgnore]
+ public Dictionary? Custom_Headers
+ {
+ get => CustomHeaders;
+ set => CustomHeaders = value;
+ }
+
+ ///
+ /// Gets or sets the HTTP verb.
+ ///
+ [Obsolete("Use HttpVerb instead.")]
+ [JsonIgnore]
+ public string? Http_Verb
+ {
+ get => HttpVerb;
+ set => HttpVerb = value;
+ }
+
+ ///
+ /// Gets or sets the HTTP body.
+ ///
+ [Obsolete("Use HttpBody instead.")]
+ [JsonIgnore]
+ public string? Http_Body
+ {
+ get => HttpBody;
+ set => HttpBody = value;
+ }
}
}
diff --git a/src/Models/Downtime.cs b/src/Models/Downtime.cs
new file mode 100644
index 0000000..1edf7a7
--- /dev/null
+++ b/src/Models/Downtime.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Text.Json.Serialization;
+
+namespace UpdownDotnet.Models
+{
+ ///
+ /// Represents a downtime period for a check.
+ ///
+ public class Downtime
+ {
+ ///
+ /// Gets or sets the error message that caused the downtime.
+ ///
+ [JsonPropertyName("error")]
+ public string? Error { get; set; }
+
+ ///
+ /// Gets or sets the time when the downtime started.
+ ///
+ [JsonPropertyName("started_at")]
+ public DateTimeOffset? StartedAt { get; set; }
+
+ ///
+ /// Gets or sets the time when the downtime ended (null if still down).
+ ///
+ [JsonPropertyName("ended_at")]
+ public DateTimeOffset? EndedAt { get; set; }
+
+ ///
+ /// Gets or sets the duration of the downtime in seconds.
+ ///
+ [JsonPropertyName("duration")]
+ public long? Duration { get; set; }
+
+ // Obsolete properties
+ ///
+ /// Gets or sets the started at time.
+ ///
+ [Obsolete("Use StartedAt instead.")]
+ [JsonIgnore]
+ public DateTimeOffset? Started_At
+ {
+ get => StartedAt;
+ set => StartedAt = value;
+ }
+
+ ///
+ /// Gets or sets the ended at time.
+ ///
+ [Obsolete("Use EndedAt instead.")]
+ [JsonIgnore]
+ public DateTimeOffset? Ended_At
+ {
+ get => EndedAt;
+ set => EndedAt = value;
+ }
+ }
+}
+
diff --git a/src/Models/Metric.cs b/src/Models/Metric.cs
new file mode 100644
index 0000000..a7ec534
--- /dev/null
+++ b/src/Models/Metric.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Text.Json.Serialization;
+
+namespace UpdownDotnet.Models
+{
+ ///
+ /// Represents performance metrics for a check during a time period.
+ ///
+ public class Metric
+ {
+ ///
+ /// Gets or sets the timestamp (in seconds since epoch) for this metric data point.
+ ///
+ [JsonPropertyName("time")]
+ public long? Time { get; set; }
+
+ ///
+ /// Gets or sets the average response time in milliseconds.
+ ///
+ [JsonPropertyName("apdex")]
+ public double? Apdex { get; set; }
+
+ ///
+ /// Gets or sets the total number of requests.
+ ///
+ [JsonPropertyName("requests")]
+ public Requests? Requests { get; set; }
+
+ ///
+ /// Gets or sets the response time statistics.
+ ///
+ [JsonPropertyName("timings")]
+ public Timings? Timings { get; set; }
+ }
+
+ ///
+ /// Represents request statistics for a metric period.
+ ///
+ public class Requests
+ {
+ ///
+ /// Gets or sets the number of samples taken.
+ ///
+ [JsonPropertyName("samples")]
+ public int? Samples { get; set; }
+
+ ///
+ /// Gets or sets the number of failures.
+ ///
+ [JsonPropertyName("failures")]
+ public int? Failures { get; set; }
+
+ ///
+ /// Gets or sets the number of satisfied responses (fast).
+ ///
+ [JsonPropertyName("satisfied")]
+ public int? Satisfied { get; set; }
+
+ ///
+ /// Gets or sets the number of tolerable responses (acceptable).
+ ///
+ [JsonPropertyName("tolerated")]
+ public int? Tolerated { get; set; }
+
+ ///
+ /// Gets or sets a breakdown of HTTP status codes.
+ ///
+ [JsonPropertyName("by_response_time")]
+ public ByResponseTime? ByResponseTime { get; set; }
+ }
+
+ ///
+ /// Represents response time categorization.
+ ///
+ public class ByResponseTime
+ {
+ ///
+ /// Gets or sets responses under 250ms.
+ ///
+ [JsonPropertyName("under250")]
+ public int? Under250 { get; set; }
+
+ ///
+ /// Gets or sets responses under 500ms.
+ ///
+ [JsonPropertyName("under500")]
+ public int? Under500 { get; set; }
+
+ ///
+ /// Gets or sets responses under 1000ms.
+ ///
+ [JsonPropertyName("under1000")]
+ public int? Under1000 { get; set; }
+
+ ///
+ /// Gets or sets responses under 2000ms.
+ ///
+ [JsonPropertyName("under2000")]
+ public int? Under2000 { get; set; }
+
+ ///
+ /// Gets or sets responses under 4000ms.
+ ///
+ [JsonPropertyName("under4000")]
+ public int? Under4000 { get; set; }
+
+ ///
+ /// Gets or sets responses over 4000ms.
+ ///
+ [JsonPropertyName("over4000")]
+ public int? Over4000 { get; set; }
+ }
+
+ ///
+ /// Represents timing statistics.
+ ///
+ public class Timings
+ {
+ ///
+ /// Gets or sets the redirect time in milliseconds.
+ ///
+ [JsonPropertyName("redirect")]
+ public int? Redirect { get; set; }
+
+ ///
+ /// Gets or sets the name lookup time in milliseconds.
+ ///
+ [JsonPropertyName("namelookup")]
+ public int? Namelookup { get; set; }
+
+ ///
+ /// Gets or sets the connection time in milliseconds.
+ ///
+ [JsonPropertyName("connection")]
+ public int? Connection { get; set; }
+
+ ///
+ /// Gets or sets the handshake time in milliseconds.
+ ///
+ [JsonPropertyName("handshake")]
+ public int? Handshake { get; set; }
+
+ ///
+ /// Gets or sets the response time in milliseconds.
+ ///
+ [JsonPropertyName("response")]
+ public int? Response { get; set; }
+
+ ///
+ /// Gets or sets the total time in milliseconds.
+ ///
+ [JsonPropertyName("total")]
+ public int? Total { get; set; }
+ }
+}
+
diff --git a/src/Models/Node.cs b/src/Models/Node.cs
new file mode 100644
index 0000000..8d24f4c
--- /dev/null
+++ b/src/Models/Node.cs
@@ -0,0 +1,118 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace UpdownDotnet.Models
+{
+ ///
+ /// Represents an Updown.io monitoring node/location.
+ ///
+ public class Node
+ {
+ ///
+ /// Gets or sets the name of the monitoring node.
+ ///
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ ///
+ /// Gets or sets the city where the node is located.
+ ///
+ [JsonPropertyName("city")]
+ public string? City { get; set; }
+
+ ///
+ /// Gets or sets the country where the node is located.
+ ///
+ [JsonPropertyName("country")]
+ public string? Country { get; set; }
+
+ ///
+ /// Gets or sets the country code (ISO 3166-1 alpha-2).
+ ///
+ [JsonPropertyName("country_code")]
+ public string? CountryCode { get; set; }
+
+ ///
+ /// Gets or sets the latitude coordinate of the node.
+ ///
+ [JsonPropertyName("lat")]
+ public double? Latitude { get; set; }
+
+ ///
+ /// Gets or sets the longitude coordinate of the node.
+ ///
+ [JsonPropertyName("lng")]
+ public double? Longitude { get; set; }
+
+ ///
+ /// Gets or sets the IPv4 address of the node.
+ ///
+ [JsonPropertyName("ip")]
+ public string? IpAddress { get; set; }
+
+ ///
+ /// Gets or sets the IPv6 address of the node.
+ ///
+ [JsonPropertyName("ip6")]
+ public string? Ipv6Address { get; set; }
+
+ // Obsolete properties
+ ///
+ /// Gets or sets the country code.
+ ///
+ [System.Obsolete("Use CountryCode instead.")]
+ [JsonIgnore]
+ public string? Country_Code
+ {
+ get => CountryCode;
+ set => CountryCode = value;
+ }
+
+ ///
+ /// Gets or sets the IPv4 address.
+ ///
+ [System.Obsolete("Use IpAddress instead.")]
+ [JsonIgnore]
+ public string? Ip
+ {
+ get => IpAddress;
+ set => IpAddress = value;
+ }
+
+ ///
+ /// Gets or sets the IPv6 address.
+ ///
+ [System.Obsolete("Use Ipv6Address instead.")]
+ [JsonIgnore]
+ public string? Ip6
+ {
+ get => Ipv6Address;
+ set => Ipv6Address = value;
+ }
+ }
+
+ ///
+ /// Represents a collection of node IPv4 addresses.
+ ///
+ public class NodeIpv4Addresses
+ {
+ ///
+ /// Gets or sets the list of IPv4 addresses.
+ ///
+ [JsonPropertyName("ipv4")]
+ public List? Ipv4 { get; set; }
+ }
+
+ ///
+ /// Represents a collection of node IPv6 addresses.
+ ///
+ public class NodeIpv6Addresses
+ {
+ ///
+ /// Gets or sets the list of IPv6 addresses.
+ ///
+ [JsonPropertyName("ipv6")]
+ public List? Ipv6 { get; set; }
+ }
+}
+
diff --git a/src/Models/Recipient.cs b/src/Models/Recipient.cs
index 051e215..7cb2e74 100644
--- a/src/Models/Recipient.cs
+++ b/src/Models/Recipient.cs
@@ -2,10 +2,20 @@
namespace UpdownDotnet.Models
{
+ ///
+ /// Represents an Updown.io notification recipient.
+ ///
public class Recipient
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
public Recipient() { }
+ ///
+ /// Initializes a new instance of the class from parameters.
+ ///
+ /// The recipient parameters.
public Recipient(RecipientParameters parameters)
{
Type = parameters.Type;
@@ -13,23 +23,52 @@ public Recipient(RecipientParameters parameters)
Value = parameters.Value;
}
+ ///
+ /// Gets or sets the unique recipient ID.
+ ///
[JsonPropertyName("id")]
- public string Id { get; set; }
+ public string? Id { get; set; }
+
+ ///
+ /// Gets or sets the recipient type (email, slack, webhook, etc.).
+ ///
[JsonPropertyName("type")]
- public string Type { get; set; }
+ public string? Type { get; set; }
+
+ ///
+ /// Gets or sets the recipient name/description.
+ ///
[JsonPropertyName("name")]
- public string Name { get; set; }
+ public string? Name { get; set; }
+
+ ///
+ /// Gets or sets the recipient value (email address, webhook URL, etc.).
+ ///
[JsonPropertyName("value")]
- public string Value { get; set; }
+ public string? Value { get; set; }
}
+ ///
+ /// Parameters for creating a recipient.
+ ///
public class RecipientParameters
{
+ ///
+ /// Gets or sets the recipient type (email, slack, webhook, etc.).
+ ///
[JsonPropertyName("type")]
- public string Type { get; set; }
+ public string? Type { get; set; }
+
+ ///
+ /// Gets or sets the recipient name/description.
+ ///
[JsonPropertyName("name")]
- public string Name { get; set; }
+ public string? Name { get; set; }
+
+ ///
+ /// Gets or sets the recipient value (email address, webhook URL, etc.).
+ ///
[JsonPropertyName("value")]
- public string Value { get; set; }
+ public string? Value { get; set; }
}
}
diff --git a/src/Models/Responses.cs b/src/Models/Responses.cs
index 29a5e39..eee70d3 100644
--- a/src/Models/Responses.cs
+++ b/src/Models/Responses.cs
@@ -2,8 +2,14 @@
namespace UpdownDotnet.Models
{
+ ///
+ /// Represents a delete operation response.
+ ///
public class DeleteResponse
{
+ ///
+ /// Gets or sets a value indicating whether the resource was deleted.
+ ///
[JsonPropertyName("deleted")]
public bool Deleted { get; set; }
}
diff --git a/src/Models/StatusPage.cs b/src/Models/StatusPage.cs
index 3a8d1ab..7cce0dd 100644
--- a/src/Models/StatusPage.cs
+++ b/src/Models/StatusPage.cs
@@ -1,48 +1,132 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace UpdownDotnet.Models
{
+ ///
+ /// Represents an Updown.io status page.
+ ///
public class StatusPage
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
public StatusPage() { }
+ ///
+ /// Initializes a new instance of the class from parameters.
+ ///
+ /// The status page parameters.
public StatusPage(StatusPageParameters parameters)
{
Name = parameters.Name;
Description = parameters.Description;
Visibility = parameters.Visibility;
- Access_Key = parameters.Access_Key;
+ AccessKey = parameters.AccessKey;
Checks = parameters.Checks;
}
+ ///
+ /// Gets or sets the unique token identifying this status page.
+ ///
[JsonPropertyName("token")]
- public string Token { get; set; }
+ public string? Token { get; set; }
+
+ ///
+ /// Gets or sets the public URL for this status page.
+ ///
[JsonPropertyName("url")]
- public string Url { get; set; }
+ public string? Url { get; set; }
+
+ ///
+ /// Gets or sets the name of the status page.
+ ///
[JsonPropertyName("name")]
- public string Name { get; set; }
+ public string? Name { get; set; }
+
+ ///
+ /// Gets or sets the description of the status page.
+ ///
[JsonPropertyName("description")]
- public string Description { get; set; }
+ public string? Description { get; set; }
+
+ ///
+ /// Gets or sets the visibility setting (public, private, etc.).
+ ///
[JsonPropertyName("visibility")]
- public string Visibility { get; set; }
+ public string? Visibility { get; set; }
+
+ ///
+ /// Gets or sets the access key for private status pages.
+ ///
[JsonPropertyName("access_key")]
- public string Access_Key { get; set; }
+ public string? AccessKey { get; set; }
+
+ ///
+ /// Gets or sets the list of check tokens included on this status page.
+ ///
[JsonPropertyName("checks")]
- public List Checks { get; set; }
+ public List? Checks { get; set; }
+
+ // Obsolete properties
+ ///
+ /// Gets or sets the access key.
+ ///
+ [Obsolete("Use AccessKey instead.")]
+ [JsonIgnore]
+ public string? Access_Key
+ {
+ get => AccessKey;
+ set => AccessKey = value;
+ }
}
+ ///
+ /// Parameters for creating or updating a status page.
+ ///
public class StatusPageParameters
{
+ ///
+ /// Gets or sets the name of the status page.
+ ///
[JsonPropertyName("name")]
- public string Name { get; set; }
+ public string? Name { get; set; }
+
+ ///
+ /// Gets or sets the description of the status page.
+ ///
[JsonPropertyName("description")]
- public string Description { get; set; }
+ public string? Description { get; set; }
+
+ ///
+ /// Gets or sets the visibility setting.
+ ///
[JsonPropertyName("visibility")]
- public string Visibility { get; set; }
+ public string? Visibility { get; set; }
+
+ ///
+ /// Gets or sets the access key for private status pages.
+ ///
[JsonPropertyName("access_key")]
- public string Access_Key { get; set; }
+ public string? AccessKey { get; set; }
+
+ ///
+ /// Gets or sets the list of check tokens to include.
+ ///
[JsonPropertyName("checks")]
- public List Checks { get; set; }
+ public List? Checks { get; set; }
+
+ // Obsolete properties
+ ///
+ /// Gets or sets the access key.
+ ///
+ [Obsolete("Use AccessKey instead.")]
+ [JsonIgnore]
+ public string? Access_Key
+ {
+ get => AccessKey;
+ set => AccessKey = value;
+ }
}
}
diff --git a/src/UpdownClient.cs b/src/UpdownClient.cs
index 7c93fa3..fb74dde 100644
--- a/src/UpdownClient.cs
+++ b/src/UpdownClient.cs
@@ -2,8 +2,21 @@
namespace UpdownDotnet
{
+ ///
+ /// Client for interacting with the Updown.io monitoring API.
+ /// This class provides methods for managing checks, recipients, status pages, and pulse monitoring.
+ ///
+ ///
+ /// The UpdownClient uses partial classes to organize API methods into logical groups.
+ /// All methods are asynchronous and support cancellation tokens.
+ ///
public partial class UpdownClient : UpdownClientBase
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The HttpClient to use for API requests.
+ /// The caller is responsible for managing the HttpClient lifecycle.
public UpdownClient(HttpClient httpClient) : base(httpClient)
{
}
diff --git a/src/UpdownClientBase.cs b/src/UpdownClientBase.cs
index 618487a..186f854 100644
--- a/src/UpdownClientBase.cs
+++ b/src/UpdownClientBase.cs
@@ -1,19 +1,40 @@
using System;
+using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
+using System.Threading;
using System.Threading.Tasks;
+using UpdownDotnet.Exceptions;
namespace UpdownDotnet
{
+ ///
+ /// Base class for Updown.io API client operations.
+ ///
public class UpdownClientBase
{
+ ///
+ /// The default Updown.io API base URL.
+ ///
public const string DefaultApiUrl = "https://updown.io";
+
+ ///
+ /// The HTTP header name for the Updown.io API key.
+ ///
public const string UpdownApiKeyHeader = "X-API-KEY";
+ ///
+ /// Gets the HttpClient used for API requests.
+ ///
protected readonly HttpClient UpdownHttpClient;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The HttpClient to use for API requests.
+ /// Thrown when httpClient is null.
public UpdownClientBase(HttpClient httpClient)
{
UpdownHttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
@@ -24,59 +45,168 @@ public UpdownClientBase(HttpClient httpClient)
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
- protected async Task DeleteAsync(Uri path)
+ ///
+ /// Handles HTTP response errors and throws appropriate exceptions.
+ ///
+ /// The HTTP response message.
+ /// Cancellation token.
+ /// Thrown when resource is not found (404).
+ /// Thrown when authentication fails (401/403).
+ /// Thrown when request is invalid (400).
+ /// Thrown when rate limit is exceeded (429).
+ /// Thrown for other API errors.
+ protected virtual async Task HandleErrorResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default)
{
- var resp = await UpdownHttpClient.DeleteAsync(path).ConfigureAwait(false);
- resp.EnsureSuccessStatusCode();
+ var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ var statusCode = response.StatusCode;
+
+ switch (statusCode)
+ {
+ case HttpStatusCode.NotFound:
+ throw new UpdownNotFoundException(
+ $"Resource not found: {response.RequestMessage?.RequestUri}",
+ content);
+
+ case HttpStatusCode.Unauthorized:
+ case HttpStatusCode.Forbidden:
+ throw new UpdownUnauthorizedException(
+ statusCode == HttpStatusCode.Unauthorized
+ ? "Authentication failed. Check your API key."
+ : "Access forbidden. You don't have permission to access this resource.",
+ statusCode,
+ content);
+
+ case HttpStatusCode.BadRequest:
+ throw new UpdownBadRequestException(
+ $"Invalid request: {content}",
+ content);
+
+ case (HttpStatusCode)429: // TooManyRequests
+ int? retryAfter = null;
+ if (response.Headers.RetryAfter?.Delta.HasValue == true)
+ {
+ retryAfter = (int)response.Headers.RetryAfter.Delta.Value.TotalSeconds;
+ }
+ throw new UpdownRateLimitException(
+ $"Rate limit exceeded. {(retryAfter.HasValue ? $"Retry after {retryAfter} seconds." : "Please try again later.")}",
+ retryAfter,
+ content);
+
+ default:
+ throw new UpdownApiException(
+ $"API request failed with status {statusCode}: {content}",
+ statusCode,
+ content);
+ }
+ }
+
+ ///
+ /// Sends a DELETE request and deserializes the response.
+ ///
+ protected async Task DeleteAsync(Uri path, CancellationToken cancellationToken = default)
+ {
+ var resp = await UpdownHttpClient.DeleteAsync(path, cancellationToken).ConfigureAwait(false);
+
+ if (!resp.IsSuccessStatusCode)
+ {
+ await HandleErrorResponseAsync(resp, cancellationToken).ConfigureAwait(false);
+ }
+
var respContent = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false);
- var result = await JsonSerializer.DeserializeAsync(respContent, JsonOptions).ConfigureAwait(false);
- return result;
+ var result = await JsonSerializer.DeserializeAsync(respContent, JsonOptions, cancellationToken).ConfigureAwait(false);
+ return result!;
}
- protected async Task GetAsync(Uri path)
+ ///
+ /// Sends a GET request and deserializes the response.
+ ///
+ protected async Task GetAsync(Uri path, CancellationToken cancellationToken = default)
{
- var resp = await UpdownHttpClient.GetAsync(path).ConfigureAwait(false);
- resp.EnsureSuccessStatusCode();
+ var resp = await UpdownHttpClient.GetAsync(path, cancellationToken).ConfigureAwait(false);
+
+ if (!resp.IsSuccessStatusCode)
+ {
+ await HandleErrorResponseAsync(resp, cancellationToken).ConfigureAwait(false);
+ }
+
var respContent = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false);
- var result = await JsonSerializer.DeserializeAsync(respContent, JsonOptions).ConfigureAwait(false);
- return result;
+ var result = await JsonSerializer.DeserializeAsync(respContent, JsonOptions, cancellationToken).ConfigureAwait(false);
+ return result!;
}
- protected async Task PostAsync(Uri path, object content)
+ ///
+ /// Sends a POST request with JSON content and deserializes the response.
+ ///
+ protected async Task PostAsync(Uri path, object content, CancellationToken cancellationToken = default)
{
var reqContent = JsonSerializer.Serialize(content, JsonOptions);
- var resp = await UpdownHttpClient.PostAsync(path, new StringContent(reqContent, Encoding.UTF8, "application/json")).ConfigureAwait(false);
- resp.EnsureSuccessStatusCode();
+ var resp = await UpdownHttpClient.PostAsync(path, new StringContent(reqContent, Encoding.UTF8, "application/json"), cancellationToken).ConfigureAwait(false);
+
+ if (!resp.IsSuccessStatusCode)
+ {
+ await HandleErrorResponseAsync(resp, cancellationToken).ConfigureAwait(false);
+ }
+
var respContent = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false);
- var result = await JsonSerializer.DeserializeAsync(respContent, JsonOptions).ConfigureAwait(false);
- return result;
+ var result = await JsonSerializer.DeserializeAsync(respContent, JsonOptions, cancellationToken).ConfigureAwait(false);
+ return result!;
}
- protected async Task PutAsync(Uri path, object content)
+ ///
+ /// Sends a PUT request with JSON content and deserializes the response.
+ ///
+ protected async Task PutAsync(Uri path, object content, CancellationToken cancellationToken = default)
{
var reqContent = JsonSerializer.Serialize(content, JsonOptions);
- var resp = await UpdownHttpClient.PutAsync(path, new StringContent(reqContent, Encoding.UTF8, "application/json")).ConfigureAwait(false);
- resp.EnsureSuccessStatusCode();
+ var resp = await UpdownHttpClient.PutAsync(path, new StringContent(reqContent, Encoding.UTF8, "application/json"), cancellationToken).ConfigureAwait(false);
+
+ if (!resp.IsSuccessStatusCode)
+ {
+ await HandleErrorResponseAsync(resp, cancellationToken).ConfigureAwait(false);
+ }
+
var respContent = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false);
- var result = await JsonSerializer.DeserializeAsync(respContent, JsonOptions).ConfigureAwait(false);
- return result;
+ var result = await JsonSerializer.DeserializeAsync(respContent, JsonOptions, cancellationToken).ConfigureAwait(false);
+ return result!;
}
- // Methods for making simple HTTP requests to absolute URLs (for pulse functionality)
- protected async Task HttpGetAsync(string absoluteUrl)
+ ///
+ /// Sends a GET request to an absolute URL (used for pulse functionality).
+ ///
+ /// The absolute URL to request.
+ /// Cancellation token.
+ /// The HTTP response message.
+ protected async Task HttpGetAsync(string absoluteUrl, CancellationToken cancellationToken = default)
{
var uri = new Uri(absoluteUrl);
- var resp = await UpdownHttpClient.GetAsync(uri).ConfigureAwait(false);
- resp.EnsureSuccessStatusCode();
+ var resp = await UpdownHttpClient.GetAsync(uri, cancellationToken).ConfigureAwait(false);
+
+ if (!resp.IsSuccessStatusCode)
+ {
+ await HandleErrorResponseAsync(resp, cancellationToken).ConfigureAwait(false);
+ }
+
return resp;
}
- protected async Task HttpPostAsync(string absoluteUrl, HttpContent content = null)
+ ///
+ /// Sends a POST request to an absolute URL (used for pulse functionality).
+ ///
+ /// The absolute URL to request.
+ /// Optional HTTP content.
+ /// Cancellation token.
+ /// The HTTP response message.
+ protected async Task HttpPostAsync(string absoluteUrl, HttpContent? content = null, CancellationToken cancellationToken = default)
{
var uri = new Uri(absoluteUrl);
- var resp = await UpdownHttpClient.PostAsync(uri, content).ConfigureAwait(false);
- resp.EnsureSuccessStatusCode();
+ var resp = await UpdownHttpClient.PostAsync(uri, content, cancellationToken).ConfigureAwait(false);
+
+ if (!resp.IsSuccessStatusCode)
+ {
+ await HandleErrorResponseAsync(resp, cancellationToken).ConfigureAwait(false);
+ }
+
return resp;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/UpdownClientBuilder.cs b/src/UpdownClientBuilder.cs
new file mode 100644
index 0000000..d42e46e
--- /dev/null
+++ b/src/UpdownClientBuilder.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+
+namespace UpdownDotnet
+{
+ ///
+ /// Builder for creating configured instances.
+ ///
+ public class UpdownClientBuilder
+ {
+ private string? _apiKey;
+ private HttpClient? _httpClient;
+ private Uri? _baseAddress;
+ private TimeSpan? _timeout;
+ private string _userAgent = UpdownClientFactory.UserAgentValue;
+
+ ///
+ /// Sets the API key for authentication.
+ ///
+ /// The Updown.io API key.
+ /// The builder instance for method chaining.
+ public UpdownClientBuilder WithApiKey(string apiKey)
+ {
+ _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey));
+ return this;
+ }
+
+ ///
+ /// Sets a custom HttpClient instance. When using this option, you are responsible
+ /// for configuring the HttpClient appropriately.
+ ///
+ /// The HttpClient to use.
+ /// The builder instance for method chaining.
+ public UpdownClientBuilder WithHttpClient(HttpClient httpClient)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ return this;
+ }
+
+ ///
+ /// Sets the base address for the API. Defaults to https://updown.io.
+ ///
+ /// The base API address.
+ /// The builder instance for method chaining.
+ public UpdownClientBuilder WithBaseAddress(Uri baseAddress)
+ {
+ _baseAddress = baseAddress ?? throw new ArgumentNullException(nameof(baseAddress));
+ return this;
+ }
+
+ ///
+ /// Sets the base address for the API using a string URL.
+ ///
+ /// The base API address as a string.
+ /// The builder instance for method chaining.
+ public UpdownClientBuilder WithBaseAddress(string baseAddress)
+ {
+ if (string.IsNullOrWhiteSpace(baseAddress))
+ throw new ArgumentException("Base address cannot be null or empty.", nameof(baseAddress));
+
+ _baseAddress = new Uri(baseAddress);
+ return this;
+ }
+
+ ///
+ /// Sets the HTTP request timeout.
+ ///
+ /// The timeout duration.
+ /// The builder instance for method chaining.
+ public UpdownClientBuilder WithTimeout(TimeSpan timeout)
+ {
+ if (timeout <= TimeSpan.Zero)
+ throw new ArgumentException("Timeout must be greater than zero.", nameof(timeout));
+
+ _timeout = timeout;
+ return this;
+ }
+
+ ///
+ /// Sets the user agent string for HTTP requests.
+ ///
+ /// The user agent string.
+ /// The builder instance for method chaining.
+ public UpdownClientBuilder WithUserAgent(string userAgent)
+ {
+ if (string.IsNullOrWhiteSpace(userAgent))
+ throw new ArgumentException("User agent cannot be null or empty.", nameof(userAgent));
+
+ _userAgent = userAgent;
+ return this;
+ }
+
+ ///
+ /// Builds and returns a configured instance.
+ ///
+ /// A configured UpdownClient instance.
+ public UpdownClient Build()
+ {
+ HttpClient httpClient;
+
+ if (_httpClient != null)
+ {
+ // Use the provided HttpClient - user is responsible for configuration
+ httpClient = _httpClient;
+ }
+ else
+ {
+ // Create a new HttpClient with proper configuration
+#if NET5_0_OR_GREATER
+ var handler = new SocketsHttpHandler
+ {
+ AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
+ PooledConnectionLifetime = TimeSpan.FromMinutes(5)
+ };
+#else
+ var handler = new HttpClientHandler
+ {
+ AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
+ };
+#endif
+
+ httpClient = new HttpClient(handler);
+
+ // Set base address
+ httpClient.BaseAddress = _baseAddress ?? new Uri(UpdownClientBase.DefaultApiUrl);
+
+ // Set timeout if specified
+ if (_timeout.HasValue)
+ {
+ httpClient.Timeout = _timeout.Value;
+ }
+
+ // Configure headers
+ httpClient.DefaultRequestHeaders.AcceptEncoding.TryParseAdd("gzip");
+ httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ httpClient.DefaultRequestHeaders.Add("user-agent", _userAgent);
+
+ // Add API key if provided
+ if (!string.IsNullOrEmpty(_apiKey))
+ {
+ httpClient.DefaultRequestHeaders.Add(UpdownClientBase.UpdownApiKeyHeader, _apiKey);
+ }
+ }
+
+ return new UpdownClient(httpClient);
+ }
+ }
+}
+
diff --git a/src/UpdownClientFactory.cs b/src/UpdownClientFactory.cs
index e150591..fa42d2e 100644
--- a/src/UpdownClientFactory.cs
+++ b/src/UpdownClientFactory.cs
@@ -5,44 +5,108 @@
namespace UpdownDotnet
{
+ ///
+ /// Factory for creating instances.
+ ///
public class UpdownClientFactory
{
+ ///
+ /// The default user agent string for HTTP requests.
+ ///
public const string UserAgentValue = "updown-dotnet";
-#if NET5_0_OR_GREATER
- private static readonly HttpClient DefaultHttpClient = new HttpClient(new SocketsHttpHandler()
+ ///
+ /// Creates a new instance with the specified API key.
+ /// Note: This method creates a new HttpClient instance for each call. For better
+ /// resource management, consider using or providing
+ /// your own HttpClient via .
+ ///
+ /// The Updown.io API key.
+ /// A configured UpdownClient instance.
+ public static UpdownClient Create(string apiKey)
{
- AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
- PooledConnectionLifetime = TimeSpan.FromMinutes(5)
- });
-#else
- private static readonly HttpClient DefaultHttpClient = new HttpClient(new HttpClientHandler()
+ if (string.IsNullOrWhiteSpace(apiKey))
+ throw new ArgumentException("API key cannot be null or empty.", nameof(apiKey));
+
+ return CreateBuilder()
+ .WithApiKey(apiKey)
+ .Build();
+ }
+
+ ///
+ /// Creates a new instance with the provided HttpClient.
+ /// The caller is responsible for managing the HttpClient lifecycle and configuration.
+ ///
+ /// The HttpClient to use for API requests.
+ /// A configured UpdownClient instance.
+ public static UpdownClient Create(HttpClient httpClient)
{
- AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
- });
-#endif
+ if (httpClient == null)
+ throw new ArgumentNullException(nameof(httpClient));
- static UpdownClientFactory()
+ return new UpdownClient(httpClient);
+ }
+
+ ///
+ /// Creates a new for fluent configuration.
+ /// This is the recommended approach for creating clients with custom configuration.
+ ///
+ /// A new UpdownClientBuilder instance.
+ ///
+ ///
+ /// var client = UpdownClientFactory.CreateBuilder()
+ /// .WithApiKey("your-api-key")
+ /// .WithTimeout(TimeSpan.FromSeconds(30))
+ /// .Build();
+ ///
+ ///
+ public static UpdownClientBuilder CreateBuilder()
{
- DefaultHttpClient.DefaultRequestHeaders.AcceptEncoding.TryParseAdd("gzip");
- DefaultHttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
- DefaultHttpClient.DefaultRequestHeaders.Add("user-agent", UserAgentValue);
- DefaultHttpClient.BaseAddress = new Uri(UpdownClientBase.DefaultApiUrl);
+ return new UpdownClientBuilder();
}
- public static UpdownClient Create(string apiKey)
+ // Deprecated: Keep for backward compatibility
+ private static HttpClient? _defaultHttpClient;
+ private static readonly object _lock = new object();
+
+ ///
+ /// Gets a shared HttpClient instance. This method is deprecated and exists only
+ /// for backward compatibility. Use instead.
+ ///
+ [Obsolete("This shared HttpClient approach has thread-safety issues. Use CreateBuilder() or provide your own HttpClient instead.")]
+ private static HttpClient GetOrCreateDefaultHttpClient()
{
- if (DefaultHttpClient.DefaultRequestHeaders.Contains(UpdownClientBase.UpdownApiKeyHeader) == false)
+ if (_defaultHttpClient == null)
{
- DefaultHttpClient.DefaultRequestHeaders.Add(UpdownClientBase.UpdownApiKeyHeader, apiKey);
- }
+ lock (_lock)
+ {
+ if (_defaultHttpClient == null)
+ {
+#if NET5_0_OR_GREATER
+ var handler = new SocketsHttpHandler
+ {
+ AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
+ PooledConnectionLifetime = TimeSpan.FromMinutes(5)
+ };
+#else
+ var handler = new HttpClientHandler
+ {
+ AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
+ };
+#endif
- return new UpdownClient(DefaultHttpClient);
- }
+ var httpClient = new HttpClient(handler);
+ httpClient.DefaultRequestHeaders.AcceptEncoding.TryParseAdd("gzip");
+ httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ httpClient.DefaultRequestHeaders.Add("user-agent", UserAgentValue);
+ httpClient.BaseAddress = new Uri(UpdownClientBase.DefaultApiUrl);
- public static UpdownClient Create(HttpClient httpClient)
- {
- return new UpdownClient(httpClient);
+ _defaultHttpClient = httpClient;
+ }
+ }
+ }
+
+ return _defaultHttpClient;
}
}
}
diff --git a/src/UpdownDotnet.csproj b/src/UpdownDotnet.csproj
index 495edb1..275b589 100644
--- a/src/UpdownDotnet.csproj
+++ b/src/UpdownDotnet.csproj
@@ -1,14 +1,15 @@
- net8.0;net6.0;netstandard2.0
+ net9.0;net8.0;net6.0;netstandard2.0
disable
- disable
+ enable
+ 9.0
embedded
false
UpdownDotnet
- 1.1.0
+ 2.0.0-rc.1
strvmarv
A simple Updown.io REST API client for .NET
README.md
@@ -17,9 +18,10 @@
updown monitoring rest api
MIT
- pulse added
+ Major improvements: .NET 9 support, nullable reference types, better error handling, improved API design, comprehensive documentation, new API endpoints (Downtimes, Metrics, Nodes)
- True
+ True
+ true
diff --git a/test/UpdownDotNetTests/BaseHttpClientTest.cs b/test/UpdownDotNetTests/BaseHttpClientTest.cs
index 117f857..75d3aed 100644
--- a/test/UpdownDotNetTests/BaseHttpClientTest.cs
+++ b/test/UpdownDotNetTests/BaseHttpClientTest.cs
@@ -4,13 +4,23 @@
namespace UpdownDotNetTests
{
+ ///
+ /// Base class for tests that require HTTP mocking with WireMock.
+ ///
public class BaseHttpClientTest : BaseTest, IDisposable
{
private readonly ILogger _logger;
-
+
+ ///
+ /// Gets the WireMock server instance for mocking HTTP responses.
+ ///
protected readonly WireMockServer Server;
+
private bool _disposedValue;
+ ///
+ /// Initializes a new instance of the class.
+ ///
public BaseHttpClientTest()
{
_logger = LoggerFactory.CreateLogger();
@@ -18,35 +28,29 @@ public BaseHttpClientTest()
Server = WireMockServer.Start();
}
- // ReSharper disable once FlagArgument
+ ///
+ /// Disposes the test resources.
+ ///
+ /// True if disposing managed resources.
protected virtual void Dispose(bool disposing)
{
- _logger.LogDebug("Server Stop");
- Server.Stop();
-
if (!_disposedValue)
{
if (disposing)
{
- // TODO: dispose managed state (managed objects)
+ _logger.LogDebug("Server Stop");
+ Server.Stop();
}
- // TODO: free unmanaged resources (unmanaged objects) and override finalizer
- // TODO: set large fields to null
_disposedValue = true;
}
}
- // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
- // ~BaseHttpClientTest()
- // {
- // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
- // Dispose(disposing: false);
- // }
-
+ ///
+ /// Disposes the test resources.
+ ///
public void Dispose()
{
- // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
diff --git a/test/UpdownDotNetTests/BaseTest.cs b/test/UpdownDotNetTests/BaseTest.cs
index 70dcfd6..70b9c9d 100644
--- a/test/UpdownDotNetTests/BaseTest.cs
+++ b/test/UpdownDotNetTests/BaseTest.cs
@@ -3,11 +3,24 @@
namespace UpdownDotNetTests
{
+ ///
+ /// Base class for tests with logging support.
+ ///
public class BaseTest
{
- protected ILogger Logger;
- protected ILoggerFactory LoggerFactory;
+ ///
+ /// Gets the logger instance.
+ ///
+ protected ILogger Logger { get; }
+
+ ///
+ /// Gets the logger factory.
+ ///
+ protected ILoggerFactory LoggerFactory { get; }
+ ///
+ /// Initializes a new instance of the class.
+ ///
protected BaseTest()
{
var serviceProvider = new ServiceCollection()
@@ -19,8 +32,7 @@ protected BaseTest()
})
.BuildServiceProvider();
- LoggerFactory = serviceProvider.GetService();
-
+ LoggerFactory = serviceProvider.GetRequiredService();
Logger = LoggerFactory.CreateLogger();
}
}
diff --git a/test/UpdownDotNetTests/Checks/ChecksManualTests.cs b/test/UpdownDotNetTests/Checks/ChecksManualTests.cs
index 488b004..35ff36c 100644
--- a/test/UpdownDotNetTests/Checks/ChecksManualTests.cs
+++ b/test/UpdownDotNetTests/Checks/ChecksManualTests.cs
@@ -13,7 +13,13 @@ namespace UpdownDotNetTests.Checks
{
public class ChecksManualTests : BaseTest
{
- private const string ApiKey = "YOUR-API-KEY-HERE";
+ // For manual testing: Set environment variable UPDOWN_API_KEY with your API key
+ // PowerShell: $env:UPDOWN_API_KEY="your-key"
+ // Bash: export UPDOWN_API_KEY="your-key"
+ // Or replace "YOUR-API-KEY-HERE" below (but don't commit!)
+ private const string ApiKeyPlaceholder = "YOUR-API-KEY-HERE";
+
+ private static string GetApiKey() => Environment.GetEnvironmentVariable("UPDOWN_API_KEY") ?? ApiKeyPlaceholder;
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions()
{
@@ -28,9 +34,10 @@ public ChecksManualTests()
_logger = LoggerFactory.CreateLogger();
}
- [TestCase(ApiKey), Explicit]
- public async Task Checks(string apiKey)
+ [Test, Explicit]
+ public async Task Checks()
{
+ var apiKey = GetApiKey();
var client = UpdownClientFactory.Create(apiKey);
var results = await client.Checks();
var result = results.FirstOrDefault();
@@ -41,9 +48,10 @@ public async Task Checks(string apiKey)
Assert.That(results, Is.Not.Null);
}
- [TestCase(ApiKey), Explicit]
- public async Task Check(string apiKey)
+ [Test, Explicit]
+ public async Task Check()
{
+ var apiKey = GetApiKey();
var client = UpdownClientFactory.Create(apiKey);
var results = await client.Checks();
var random = new Random();
@@ -59,9 +67,10 @@ public async Task Check(string apiKey)
});
}
- [TestCase(ApiKey), Explicit]
- public async Task CheckCreateUpdateDelete(string apiKey)
+ [Test, Explicit]
+ public async Task CheckCreateUpdateDelete()
{
+ var apiKey = GetApiKey();
var parameters = new CheckParameters
{
Url = "https://www.radancy.com",
diff --git a/test/UpdownDotNetTests/Checks/ChecksTests.cs b/test/UpdownDotNetTests/Checks/ChecksTests.cs
index 0dfd894..ca9f3a0 100644
--- a/test/UpdownDotNetTests/Checks/ChecksTests.cs
+++ b/test/UpdownDotNetTests/Checks/ChecksTests.cs
@@ -73,7 +73,7 @@ public async Task Check()
}
[Test]
- public void CheckNotFound()
+ public void CheckNotFound_ThrowsUpdownNotFoundException()
{
var mockInput = new Check { Token = "token", Url = "https://i-am-a-test.com" };
@@ -85,7 +85,7 @@ public void CheckNotFound()
var client = UpdownClientFactory.Create(Server.CreateClient());
- Assert.ThrowsAsync(() => client.Check(mockInput.Token));
+ Assert.ThrowsAsync(() => client.Check(mockInput.Token));
}
[Test]
@@ -113,7 +113,7 @@ public async Task CheckCreate()
}
[Test]
- public void CheckCreateNotFound()
+ public void CheckCreateNotFound_ThrowsUpdownNotFoundException()
{
var mockParameters = new CheckParameters { Url = "https://i-am-a-test.com" };
@@ -124,7 +124,7 @@ public void CheckCreateNotFound()
.WithNotFound());
var client = UpdownClientFactory.Create(Server.CreateClient());
- Assert.ThrowsAsync(() => client.CheckCreate(mockParameters));
+ Assert.ThrowsAsync(() => client.CheckCreate(mockParameters));
}
[Test]
@@ -148,7 +148,7 @@ public async Task CheckDelete()
}
[Test]
- public void CheckDeleteNotFound()
+ public void CheckDeleteNotFound_ThrowsUpdownNotFoundException()
{
var mockInput = new Check { Token = "token", Url = "https://i-am-a-test.com" };
@@ -160,7 +160,7 @@ public void CheckDeleteNotFound()
.WithNotFound());
var client = UpdownClientFactory.Create(Server.CreateClient());
- Assert.ThrowsAsync(() => client.CheckDelete(mockInput.Token));
+ Assert.ThrowsAsync(() => client.CheckDelete(mockInput.Token));
}
[Test]
@@ -184,7 +184,7 @@ public async Task CheckUpdate()
}
[Test]
- public void CheckUpdateNotFound()
+ public void CheckUpdateNotFound_ThrowsUpdownNotFoundException()
{
var mockParameters = new CheckParameters { Url = "https://i-am-a-test.com" };
@@ -197,7 +197,7 @@ public void CheckUpdateNotFound()
var client = UpdownClientFactory.Create(Server.CreateClient());
- Assert.ThrowsAsync(() => client.CheckUpdate("token", mockParameters));
+ Assert.ThrowsAsync(() => client.CheckUpdate("token", mockParameters));
}
}
}
diff --git a/test/UpdownDotNetTests/Downtimes/DowntimesTests.cs b/test/UpdownDotNetTests/Downtimes/DowntimesTests.cs
new file mode 100644
index 0000000..b64d9c1
--- /dev/null
+++ b/test/UpdownDotNetTests/Downtimes/DowntimesTests.cs
@@ -0,0 +1,126 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using NUnit.Framework;
+using UpdownDotnet;
+using UpdownDotnet.Models;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+
+namespace UpdownDotNetTests.Downtimes
+{
+ ///
+ /// Tests for the Downtimes API.
+ ///
+ [TestFixture]
+ public class DowntimesTests : BaseHttpClientTest
+ {
+ private readonly JsonSerializerOptions _jsonSerializerOptions = new JsonSerializerOptions
+ {
+ WriteIndented = true
+ };
+
+ private readonly ILogger _logger;
+
+ public DowntimesTests()
+ {
+ _logger = LoggerFactory.CreateLogger();
+ }
+
+ [Test]
+ public async Task DowntimesAsync_ReturnsListOfDowntimes_WhenSuccessful()
+ {
+ // Arrange - Use JSON string to ensure proper property name matching
+ var mockJson = @"[{
+ ""error"": ""Connection timeout"",
+ ""started_at"": ""2023-01-01T10:00:00Z"",
+ ""ended_at"": ""2023-01-01T11:00:00Z"",
+ ""duration"": 3600
+ }]";
+
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}/test-token/downtimes")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(mockJson));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act
+ var results = await client.DowntimesAsync("test-token");
+
+ // Assert
+ Assert.That(results, Is.Not.Null);
+ Assert.That(results.Count, Is.EqualTo(1));
+ Assert.That(results[0], Is.Not.Null);
+ Assert.That(results[0].Duration, Is.EqualTo(3600));
+
+ _logger.LogDebug(JsonSerializer.Serialize(results, _jsonSerializerOptions));
+ }
+
+ [Test]
+ public async Task DowntimesAsync_WithPagination_ReturnsCorrectPage()
+ {
+ // Arrange - Use JSON string to ensure proper property name matching
+ var mockJson = @"[{
+ ""error"": ""Page 2 downtime"",
+ ""started_at"": ""2023-01-01T06:00:00Z"",
+ ""ended_at"": ""2023-01-01T07:00:00Z"",
+ ""duration"": 3600
+ }]";
+
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}/test-token/downtimes")
+ .WithParam("page", "2")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(mockJson));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act
+ var results = await client.DowntimesAsync("test-token", page: 2);
+
+ // Assert
+ Assert.That(results, Is.Not.Null);
+ Assert.That(results.Count, Is.EqualTo(1));
+ Assert.That(results[0], Is.Not.Null);
+ }
+
+ [Test]
+ public void DowntimesAsync_ThrowsArgumentException_WhenTokenIsNullOrEmpty()
+ {
+ // Arrange
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act & Assert
+ Assert.ThrowsAsync(() => client.DowntimesAsync(null!));
+ Assert.ThrowsAsync(() => client.DowntimesAsync(string.Empty));
+ Assert.ThrowsAsync(() => client.DowntimesAsync(" "));
+ }
+
+ [Test]
+ public void DowntimesAsync_ThrowsHttpRequestException_WhenNotFound()
+ {
+ // Arrange
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}/nonexistent/downtimes")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(404));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act & Assert
+ Assert.ThrowsAsync(() => client.DowntimesAsync("nonexistent"));
+ }
+ }
+}
+
diff --git a/test/UpdownDotNetTests/ErrorHandling/ErrorScenarioTests.cs b/test/UpdownDotNetTests/ErrorHandling/ErrorScenarioTests.cs
new file mode 100644
index 0000000..e39a0be
--- /dev/null
+++ b/test/UpdownDotNetTests/ErrorHandling/ErrorScenarioTests.cs
@@ -0,0 +1,217 @@
+using System;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using NUnit.Framework;
+using UpdownDotnet;
+using UpdownDotnet.Exceptions;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+
+namespace UpdownDotNetTests.ErrorHandling
+{
+ [TestFixture]
+ public class ErrorScenarioTests : BaseHttpClientTest
+ {
+ private readonly ILogger _logger;
+
+ public ErrorScenarioTests()
+ {
+ _logger = LoggerFactory.CreateLogger();
+ }
+
+ [Test]
+ public void RateLimitExceeded_ThrowsUpdownRateLimitException_WithRetryAfter()
+ {
+ // Arrange
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(429)
+ .WithHeader("Retry-After", "60")
+ .WithBody("{\"error\":\"Rate limit exceeded\"}"));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act & Assert
+ var ex = Assert.ThrowsAsync(() => client.ChecksAsync());
+ Assert.That(ex!.RetryAfterSeconds, Is.EqualTo(60));
+ _logger.LogInformation($"Rate limit exception: {ex.Message}");
+ }
+
+ [Test]
+ public void BadRequest_ThrowsUpdownBadRequestException()
+ {
+ // Arrange
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}")
+ .UsingPost())
+ .RespondWith(Response.Create()
+ .WithStatusCode(400)
+ .WithBody("{\"error\":\"Invalid URL format\"}"));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+ var parameters = new UpdownDotnet.Models.CheckParameters { Url = "invalid-url" };
+
+ // Act & Assert
+ var ex = Assert.ThrowsAsync(() => client.CheckCreateAsync(parameters));
+ Assert.That(ex, Is.Not.Null);
+ Assert.That(ex!.ResponseContent, Does.Contain("Invalid URL"));
+ _logger.LogInformation($"Bad request exception: {ex.Message}");
+ }
+
+ [Test]
+ public void Unauthorized_ThrowsUpdownUnauthorizedException()
+ {
+ // Arrange
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(401)
+ .WithBody("{\"error\":\"Invalid API key\"}"));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act & Assert
+ var ex = Assert.ThrowsAsync(() => client.ChecksAsync());
+ Assert.That(ex, Is.Not.Null);
+ Assert.That(ex!.ResponseContent, Does.Contain("Invalid API key"));
+ _logger.LogInformation($"Unauthorized exception: {ex.Message}");
+ }
+
+ [Test]
+ public void ServerError_ThrowsUpdownApiException_With500Status()
+ {
+ // Arrange
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(500)
+ .WithBody("{\"error\":\"Internal server error\"}"));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act & Assert
+ var ex = Assert.ThrowsAsync(() => client.ChecksAsync());
+ Assert.That(ex, Is.Not.Null);
+ Assert.That(ex!.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError));
+ _logger.LogInformation($"Server error exception: {ex.Message}");
+ }
+
+ [Test]
+ public async Task CancellationToken_WhenCancelled_ThrowsOperationCanceledException()
+ {
+ // Arrange
+ using var cts = new CancellationTokenSource();
+ cts.Cancel(); // Cancel immediately
+
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithBody("[]")
+ .WithDelay(TimeSpan.FromSeconds(10))); // Long delay to ensure cancellation
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act & Assert
+ // TaskCanceledException is derived from OperationCanceledException
+ Assert.ThrowsAsync(() => client.ChecksAsync(cts.Token));
+ }
+
+ [Test]
+ public async Task CancellationToken_WhenNotCancelled_CompletesSuccessfully()
+ {
+ // Arrange
+ using var cts = new CancellationTokenSource();
+
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithBody("[]"));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act
+ var result = await client.ChecksAsync(cts.Token);
+
+ // Assert
+ Assert.That(result, Is.Not.Null);
+ _logger.LogInformation("Request completed successfully with cancellation token");
+ }
+
+ [Test]
+ public async Task MultipleConcurrentRequests_WithDifferentTokens_HandleCorrectly()
+ {
+ // Arrange
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithBody("[]"));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act - Fire multiple concurrent requests
+ var task1 = client.ChecksAsync();
+ var task2 = client.ChecksAsync();
+ var task3 = client.ChecksAsync();
+
+ await Task.WhenAll(task1, task2, task3);
+
+ // Assert
+ Assert.That(task1.Result, Is.Not.Null);
+ Assert.That(task2.Result, Is.Not.Null);
+ Assert.That(task3.Result, Is.Not.Null);
+ _logger.LogInformation("Multiple concurrent requests completed successfully");
+ }
+
+ [Test]
+ public void ServiceUnavailable_ThrowsUpdownApiException_With503Status()
+ {
+ // Arrange
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(503)
+ .WithBody("{\"error\":\"Service temporarily unavailable\"}"));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act & Assert
+ var ex = Assert.ThrowsAsync(() => client.ChecksAsync());
+ Assert.That(ex, Is.Not.Null);
+ Assert.That(ex!.StatusCode, Is.EqualTo(HttpStatusCode.ServiceUnavailable));
+ _logger.LogInformation($"Service unavailable exception: {ex.Message}");
+ }
+
+ [Test]
+ public void EmptyResponseBody_HandlesGracefully()
+ {
+ // Arrange
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}/nonexistent")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(404)
+ .WithBody("")); // Empty response body
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act & Assert
+ var ex = Assert.ThrowsAsync(() => client.CheckAsync("nonexistent"));
+ Assert.That(ex, Is.Not.Null);
+ _logger.LogInformation("Empty response body handled correctly");
+ }
+ }
+}
+
diff --git a/test/UpdownDotNetTests/Metrics/MetricsTests.cs b/test/UpdownDotNetTests/Metrics/MetricsTests.cs
new file mode 100644
index 0000000..65c59f2
--- /dev/null
+++ b/test/UpdownDotNetTests/Metrics/MetricsTests.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using NUnit.Framework;
+using UpdownDotnet;
+using UpdownDotnet.Models;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+
+namespace UpdownDotNetTests.Metrics
+{
+ ///
+ /// Tests for the Metrics API.
+ ///
+ [TestFixture]
+ public class MetricsTests : BaseHttpClientTest
+ {
+ private readonly JsonSerializerOptions _jsonSerializerOptions = new JsonSerializerOptions
+ {
+ WriteIndented = true
+ };
+
+ private readonly ILogger _logger;
+
+ public MetricsTests()
+ {
+ _logger = LoggerFactory.CreateLogger();
+ }
+
+ [Test]
+ public async Task MetricsAsync_ReturnsListOfMetrics_WhenSuccessful()
+ {
+ // Arrange
+ var mockResult = new List
+ {
+ new Metric
+ {
+ Time = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
+ Apdex = 0.95,
+ Requests = new Requests
+ {
+ Samples = 100,
+ Failures = 2,
+ Satisfied = 90,
+ Tolerated = 8
+ },
+ Timings = new Timings
+ {
+ Redirect = 0,
+ Namelookup = 15,
+ Connection = 25,
+ Handshake = 35,
+ Response = 120,
+ Total = 195
+ }
+ }
+ };
+
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}/test-token/metrics")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithBodyAsJson(mockResult));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act
+ var results = await client.MetricsAsync("test-token");
+
+ // Assert
+ Assert.That(results, Is.Not.Null);
+ Assert.That(results.Count, Is.EqualTo(1));
+ Assert.That(results[0], Is.Not.Null);
+ // Note: These may be null based on serialization
+ if (results[0].Requests != null)
+ {
+ Assert.That(results[0].Requests.Samples, Is.EqualTo(100));
+ }
+
+ _logger.LogDebug(JsonSerializer.Serialize(results, _jsonSerializerOptions));
+ }
+
+ [Test]
+ public async Task MetricsAsync_WithTimeRange_IncludesQueryParameters()
+ {
+ // Arrange
+ var mockResult = new List
+ {
+ new Metric
+ {
+ Time = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
+ Apdex = 0.98
+ }
+ };
+
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}/test-token/metrics")
+ .WithParam("from", "2024-01-01")
+ .WithParam("to", "2024-01-31")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithBodyAsJson(mockResult));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act
+ var results = await client.MetricsAsync("test-token", from: "2024-01-01", to: "2024-01-31");
+
+ // Assert
+ Assert.That(results, Is.Not.Null);
+ Assert.That(results.Count, Is.EqualTo(1));
+ }
+
+ [Test]
+ public async Task MetricsAsync_WithGroupParameter_IncludesInQuery()
+ {
+ // Arrange
+ var mockResult = new List();
+
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}/test-token/metrics")
+ .WithParam("group", "host")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithBodyAsJson(mockResult));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act
+ var results = await client.MetricsAsync("test-token", group: "host");
+
+ // Assert
+ Assert.That(results, Is.Not.Null);
+ }
+
+ [Test]
+ public void MetricsAsync_ThrowsArgumentException_WhenTokenIsNullOrEmpty()
+ {
+ // Arrange
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act & Assert
+ Assert.ThrowsAsync(() => client.MetricsAsync(null!));
+ Assert.ThrowsAsync(() => client.MetricsAsync(string.Empty));
+ Assert.ThrowsAsync(() => client.MetricsAsync(" "));
+ }
+
+ [Test]
+ public void MetricsAsync_ThrowsHttpRequestException_WhenNotFound()
+ {
+ // Arrange
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.ChecksPath}/nonexistent/metrics")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(404));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act & Assert
+ Assert.ThrowsAsync(() => client.MetricsAsync("nonexistent"));
+ }
+ }
+}
+
diff --git a/test/UpdownDotNetTests/Nodes/NodesTests.cs b/test/UpdownDotNetTests/Nodes/NodesTests.cs
new file mode 100644
index 0000000..dcf3e13
--- /dev/null
+++ b/test/UpdownDotNetTests/Nodes/NodesTests.cs
@@ -0,0 +1,143 @@
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using NUnit.Framework;
+using UpdownDotnet;
+using UpdownDotnet.Models;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+
+namespace UpdownDotNetTests.Nodes
+{
+ ///
+ /// Tests for the Nodes API.
+ ///
+ [TestFixture]
+ public class NodesTests : BaseHttpClientTest
+ {
+ private readonly JsonSerializerOptions _jsonSerializerOptions = new JsonSerializerOptions
+ {
+ WriteIndented = true
+ };
+
+ private readonly ILogger _logger;
+
+ public NodesTests()
+ {
+ _logger = LoggerFactory.CreateLogger();
+ }
+
+ [Test]
+ public async Task NodesAsync_ReturnsListOfNodes_WhenSuccessful()
+ {
+ // Arrange - Use explicit JSON string to ensure proper property name matching
+ var mockJson = @"[
+ {
+ ""name"": ""nyc"",
+ ""city"": ""New York"",
+ ""country"": ""United States"",
+ ""country_code"": ""US"",
+ ""lat"": 40.7128,
+ ""lng"": -74.0060,
+ ""ip"": ""1.2.3.4"",
+ ""ip6"": ""2001:db8::1""
+ },
+ {
+ ""name"": ""lon"",
+ ""city"": ""London"",
+ ""country"": ""United Kingdom"",
+ ""country_code"": ""GB"",
+ ""lat"": 51.5074,
+ ""lng"": -0.1278,
+ ""ip"": ""5.6.7.8"",
+ ""ip6"": ""2001:db8::2""
+ }
+ ]";
+
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.NodesPath}")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(mockJson));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act
+ var results = await client.NodesAsync();
+
+ // Assert
+ Assert.That(results, Is.Not.Null);
+ Assert.That(results.Count, Is.EqualTo(2));
+ Assert.That(results[0].Name, Is.EqualTo("nyc"));
+ Assert.That(results[0].City, Is.EqualTo("New York"));
+ Assert.That(results[0].CountryCode, Is.EqualTo("US"));
+ Assert.That(results[1].Name, Is.EqualTo("lon"));
+
+ _logger.LogDebug(JsonSerializer.Serialize(results, _jsonSerializerOptions));
+ }
+
+ [Test]
+ public async Task NodesIpv4Async_ReturnsIpv4Addresses_WhenSuccessful()
+ {
+ // Arrange - Use explicit JSON string to ensure proper property name matching
+ var mockJson = @"{
+ ""ipv4"": [""1.2.3.4"", ""5.6.7.8"", ""9.10.11.12""]
+ }";
+
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.NodesPath}/ipv4")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(mockJson));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act
+ var result = await client.NodesIpv4Async();
+
+ // Assert
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.Ipv4, Is.Not.Null);
+ Assert.That(result.Ipv4!.Count, Is.EqualTo(3));
+ Assert.That(result.Ipv4, Does.Contain("1.2.3.4"));
+
+ _logger.LogDebug(JsonSerializer.Serialize(result, _jsonSerializerOptions));
+ }
+
+ [Test]
+ public async Task NodesIpv6Async_ReturnsIpv6Addresses_WhenSuccessful()
+ {
+ // Arrange - Use explicit JSON string to ensure proper property name matching
+ var mockJson = @"{
+ ""ipv6"": [""2001:db8::1"", ""2001:db8::2""]
+ }";
+
+ Server.Given(Request.Create()
+ .WithPath($"/{UpdownClient.NodesPath}/ipv6")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(mockJson));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act
+ var result = await client.NodesIpv6Async();
+
+ // Assert
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.Ipv6, Is.Not.Null);
+ Assert.That(result.Ipv6!.Count, Is.EqualTo(2));
+ Assert.That(result.Ipv6, Does.Contain("2001:db8::1"));
+
+ _logger.LogDebug(JsonSerializer.Serialize(result, _jsonSerializerOptions));
+ }
+ }
+}
+
diff --git a/test/UpdownDotNetTests/Pulse/PulseManualTests.cs b/test/UpdownDotNetTests/Pulse/PulseManualTests.cs
index 3361f23..d08e19c 100644
--- a/test/UpdownDotNetTests/Pulse/PulseManualTests.cs
+++ b/test/UpdownDotNetTests/Pulse/PulseManualTests.cs
@@ -8,8 +8,15 @@ namespace UpdownDotNetTests.Pulse
{
public class PulseManualTests : BaseTest
{
- private const string ApiKey = "YOUR-API-KEY-HERE";
- private const string TestPulseUrl = "YOUR-PULSE-URL-HERE";
+ // For manual testing: Set environment variables UPDOWN_API_KEY and UPDOWN_PULSE_URL
+ // PowerShell: $env:UPDOWN_API_KEY="your-key"; $env:UPDOWN_PULSE_URL="https://pulse.updown.io/..."
+ // Bash: export UPDOWN_API_KEY="your-key" UPDOWN_PULSE_URL="https://pulse.updown.io/..."
+ // Or replace placeholders below (but don't commit!)
+ private const string ApiKeyPlaceholder = "YOUR-API-KEY-HERE";
+ private const string PulseUrlPlaceholder = "YOUR-PULSE-URL-HERE";
+
+ private static string GetApiKey() => Environment.GetEnvironmentVariable("UPDOWN_API_KEY") ?? ApiKeyPlaceholder;
+ private static string GetPulseUrl() => Environment.GetEnvironmentVariable("UPDOWN_PULSE_URL") ?? PulseUrlPlaceholder;
private readonly ILogger _logger;
@@ -18,9 +25,11 @@ public PulseManualTests()
_logger = LoggerFactory.CreateLogger();
}
- [TestCase(ApiKey, TestPulseUrl), Explicit]
- public async Task SendPulse(string apiKey, string pulseUrl)
+ [Test, Explicit]
+ public async Task SendPulse()
{
+ var apiKey = GetApiKey();
+ var pulseUrl = GetPulseUrl();
var client = UpdownClientFactory.Create(apiKey);
_logger.LogInformation($"Sending pulse using URL: {pulseUrl}");
@@ -40,9 +49,11 @@ public async Task SendPulse(string apiKey, string pulseUrl)
}
}
- [TestCase(ApiKey, TestPulseUrl), Explicit]
- public async Task SendPulsePost(string apiKey, string pulseUrl)
+ [Test, Explicit]
+ public async Task SendPulsePost()
{
+ var apiKey = GetApiKey();
+ var pulseUrl = GetPulseUrl();
var client = UpdownClientFactory.Create(apiKey);
_logger.LogInformation($"Sending pulse POST using URL: {pulseUrl}");
diff --git a/test/UpdownDotNetTests/Pulse/PulseTests.cs b/test/UpdownDotNetTests/Pulse/PulseTests.cs
index ee2554d..3c8f4fe 100644
--- a/test/UpdownDotNetTests/Pulse/PulseTests.cs
+++ b/test/UpdownDotNetTests/Pulse/PulseTests.cs
@@ -57,31 +57,31 @@ public async Task SendPulsePost_WithValidUrl_SendsPostRequest()
}
[Test]
- public void SendPulse_WithNullUrl_ThrowsArgumentNullException()
+ public void SendPulse_WithNullUrl_ThrowsArgumentException()
{
var client = UpdownClientFactory.Create(Server.CreateClient());
- Assert.ThrowsAsync(() => client.SendPulse(null));
+ Assert.ThrowsAsync(() => client.SendPulse(null!));
}
[Test]
- public void SendPulse_WithEmptyUrl_ThrowsArgumentNullException()
+ public void SendPulse_WithEmptyUrl_ThrowsArgumentException()
{
var client = UpdownClientFactory.Create(Server.CreateClient());
- Assert.ThrowsAsync(() => client.SendPulse(""));
+ Assert.ThrowsAsync(() => client.SendPulse(""));
}
[Test]
- public void SendPulse_WithWhitespaceUrl_ThrowsArgumentNullException()
+ public void SendPulse_WithWhitespaceUrl_ThrowsArgumentException()
{
var client = UpdownClientFactory.Create(Server.CreateClient());
- Assert.ThrowsAsync(() => client.SendPulse(" "));
+ Assert.ThrowsAsync(() => client.SendPulse(" "));
}
[Test]
- public void SendPulse_WithInvalidUrl_ThrowsHttpRequestException()
+ public void SendPulse_WithInvalidUrl_ThrowsUpdownNotFoundException()
{
// Use the mock server URL instead of updown.io
var pulseUrl = $"{Server.Url}/p/invalid-token";
@@ -95,7 +95,7 @@ public void SendPulse_WithInvalidUrl_ThrowsHttpRequestException()
var client = UpdownClientFactory.Create(Server.CreateClient());
- Assert.ThrowsAsync(() => client.SendPulse(pulseUrl));
+ Assert.ThrowsAsync(() => client.SendPulse(pulseUrl));
}
[Test]
diff --git a/test/UpdownDotNetTests/README.md b/test/UpdownDotNetTests/README.md
new file mode 100644
index 0000000..7dbd667
--- /dev/null
+++ b/test/UpdownDotNetTests/README.md
@@ -0,0 +1,161 @@
+# UpdownDotnet Tests
+
+This directory contains comprehensive unit and manual tests for the Updown.io .NET Client.
+
+## Test Organization
+
+- **Checks/** - Tests for the Checks API
+- **Downtimes/** - Tests for the Downtimes API
+- **ErrorHandling/** - Tests for error scenarios and exception handling
+- **Metrics/** - Tests for the Metrics API
+- **Nodes/** - Tests for the Nodes API
+- **Pulse/** - Tests for Pulse (heartbeat) monitoring
+- **Recipients/** - Tests for the Recipients API
+- **StatusPages/** - Tests for the Status Pages API
+
+## Running Tests
+
+### Unit Tests (Default)
+
+All automated unit tests use WireMock.Net to mock HTTP responses and do not require an API key:
+
+```powershell
+dotnet test
+```
+
+These tests run automatically in CI/CD and should always pass without any configuration.
+
+### Manual Tests (Optional)
+
+Manual tests are marked with `[Explicit]` and require a real Updown.io API key. They are **disabled by default** and must be explicitly run.
+
+⚠️ **IMPORTANT: Never commit your API keys to the repository!**
+
+#### Option 1: Command Line (Recommended)
+
+Run specific manual tests by passing your API key as a parameter:
+
+```powershell
+# Run all manual tests for Checks
+dotnet test --filter "FullyQualifiedName~ChecksManualTests"
+
+# Run specific manual test
+dotnet test --filter "FullyQualifiedName~ChecksManualTests.Checks"
+```
+
+**Note:** You'll need to temporarily edit the test file to replace `"YOUR-API-KEY-HERE"` with your actual key, then **revert the change** before committing.
+
+#### Option 2: Using Environment Variables (Better Security)
+
+You can modify the manual test files to read from environment variables:
+
+```csharp
+private static readonly string ApiKey =
+ Environment.GetEnvironmentVariable("UPDOWN_API_KEY") ?? "YOUR-API-KEY-HERE";
+```
+
+Then set the environment variable:
+
+```powershell
+# PowerShell
+$env:UPDOWN_API_KEY="your-actual-api-key"
+dotnet test --filter "FullyQualifiedName~ChecksManualTests"
+
+# Or set permanently for your user
+[System.Environment]::SetEnvironmentVariable('UPDOWN_API_KEY','your-key','User')
+```
+
+#### Option 3: Using Configuration File (Most Secure)
+
+Create a local configuration file (already in .gitignore):
+
+**test/UpdownDotNetTests/TestSettings.json** (this file is git-ignored):
+```json
+{
+ "UpdownApiKey": "your-actual-api-key-here",
+ "PulseUrl": "https://pulse.updown.io/YOUR-TOKEN/YOUR-KEY"
+}
+```
+
+This file will never be committed thanks to `.gitignore` entries.
+
+## Security Best Practices
+
+### ✅ DO:
+- Use placeholder strings like `"YOUR-API-KEY-HERE"` in committed code
+- Use environment variables for local testing
+- Use git-ignored configuration files for API keys
+- Revert any changes to test files before committing
+- Use separate test/development API keys (not production)
+
+### ❌ DON'T:
+- Commit real API keys to the repository
+- Share API keys in pull requests or issues
+- Use production API keys for testing
+- Push configuration files with secrets
+
+## Checking for Leaked Keys
+
+Before committing, verify no keys are present:
+
+```powershell
+# Search for potential API keys (should only find placeholders)
+git grep -i "api.key" test/
+git grep -E "[a-z0-9]{32,}" test/ --exclude-dir=bin --exclude-dir=obj
+
+# Check what you're about to commit
+git diff --cached
+```
+
+## CI/CD Testing
+
+Automated tests in GitHub Actions only run unit tests (with WireMock.Net). Manual tests requiring real API keys are never run in CI/CD for security reasons.
+
+## Test Coverage
+
+Current test coverage:
+- **49 unit tests** - All passing on .NET 6, 8, and 9
+- **7 manual test methods** - For optional real API testing
+- **100% API endpoint coverage** - All public methods tested
+
+## Writing New Tests
+
+### Unit Tests (Preferred)
+```csharp
+[Test]
+public async Task MethodName_Scenario_ExpectedOutcome()
+{
+ // Arrange
+ Server.Given(Request.Create()
+ .WithPath("/api/checks")
+ .UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithBody("[{\"token\":\"abc\"}]"));
+
+ var client = UpdownClientFactory.Create(Server.CreateClient());
+
+ // Act
+ var result = await client.ChecksAsync();
+
+ // Assert
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.Count, Is.EqualTo(1));
+}
+```
+
+### Manual Tests (For Real API Validation)
+```csharp
+[TestCase("YOUR-API-KEY-HERE"), Explicit]
+public async Task ManualTestName(string apiKey)
+{
+ // Test implementation using real API
+}
+```
+
+## Additional Resources
+
+- [NUnit Documentation](https://docs.nunit.org/)
+- [WireMock.Net Documentation](https://github.com/WireMock-Net/WireMock.Net)
+- [Updown.io API Documentation](https://updown.io/api)
+
diff --git a/test/UpdownDotNetTests/Recipients/RecipientsManualTests.cs b/test/UpdownDotNetTests/Recipients/RecipientsManualTests.cs
index 85b5c4e..204daad 100644
--- a/test/UpdownDotNetTests/Recipients/RecipientsManualTests.cs
+++ b/test/UpdownDotNetTests/Recipients/RecipientsManualTests.cs
@@ -12,7 +12,13 @@ namespace UpdownDotNetTests.Recipients
{
public class RecipientsManualTests : BaseTest
{
- private const string ApiKey = "YOUR-API-KEY-HERE";
+ // For manual testing: Set environment variable UPDOWN_API_KEY with your API key
+ // PowerShell: $env:UPDOWN_API_KEY="your-key"
+ // Bash: export UPDOWN_API_KEY="your-key"
+ // Or replace "YOUR-API-KEY-HERE" below (but don't commit!)
+ private const string ApiKeyPlaceholder = "YOUR-API-KEY-HERE";
+
+ private static string GetApiKey() => Environment.GetEnvironmentVariable("UPDOWN_API_KEY") ?? ApiKeyPlaceholder;
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions()
{
@@ -27,9 +33,10 @@ public RecipientsManualTests()
_logger = LoggerFactory.CreateLogger();
}
- [TestCase(ApiKey), Explicit]
- public async Task Recipients(string apiKey)
+ [Test, Explicit]
+ public async Task Recipients()
{
+ var apiKey = GetApiKey();
var client = UpdownClientFactory.Create(apiKey);
var results = await client.Recipients();
var result = results.FirstOrDefault();
@@ -40,9 +47,10 @@ public async Task Recipients(string apiKey)
Assert.That(results, Is.Not.Null);
}
- [TestCase(ApiKey), Explicit]
- public async Task RecipientCreateDelete(string apiKey)
+ [Test, Explicit]
+ public async Task RecipientCreateDelete()
{
+ var apiKey = GetApiKey();
var parameters = new RecipientParameters
{
Name = "Test",
diff --git a/test/UpdownDotNetTests/Recipients/RecipientsTests.cs b/test/UpdownDotNetTests/Recipients/RecipientsTests.cs
index 44d7768..29d7f1c 100644
--- a/test/UpdownDotNetTests/Recipients/RecipientsTests.cs
+++ b/test/UpdownDotNetTests/Recipients/RecipientsTests.cs
@@ -79,7 +79,7 @@ public async Task RecipientCreate()
}
[Test]
- public void RecipientCreateNotFound()
+ public void RecipientCreateNotFound_ThrowsUpdownNotFoundException()
{
var mockParameters = new RecipientParameters
{
@@ -95,7 +95,7 @@ public void RecipientCreateNotFound()
.WithNotFound());
var client = UpdownClientFactory.Create(Server.CreateClient());
- Assert.ThrowsAsync(() => client.RecipientCreate(mockParameters));
+ Assert.ThrowsAsync(() => client.RecipientCreate(mockParameters));
}
[Test]
@@ -122,7 +122,7 @@ public async Task RecipientDelete()
}
[Test]
- public void RecipientDeleteNotFound()
+ public void RecipientDeleteNotFound_ThrowsUpdownNotFoundException()
{
var mockInput = new Recipient
{
@@ -137,7 +137,7 @@ public void RecipientDeleteNotFound()
.WithNotFound());
var client = UpdownClientFactory.Create(Server.CreateClient());
- Assert.ThrowsAsync(() => client.RecipientDelete(mockInput.Id));
+ Assert.ThrowsAsync(() => client.RecipientDelete(mockInput.Id!));
}
}
}
diff --git a/test/UpdownDotNetTests/StatusPages/StatusPageManualTests.cs b/test/UpdownDotNetTests/StatusPages/StatusPageManualTests.cs
index 16a07c3..e92f8ee 100644
--- a/test/UpdownDotNetTests/StatusPages/StatusPageManualTests.cs
+++ b/test/UpdownDotNetTests/StatusPages/StatusPageManualTests.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
@@ -12,7 +13,13 @@ namespace UpdownDotNetTests.StatusPages
{
public class StatusPageManualTests : BaseTest
{
- private const string ApiKey = "YOUR-API-KEY-HERE";
+ // For manual testing: Set environment variable UPDOWN_API_KEY with your API key
+ // PowerShell: $env:UPDOWN_API_KEY="your-key"
+ // Bash: export UPDOWN_API_KEY="your-key"
+ // Or replace "YOUR-API-KEY-HERE" below (but don't commit!)
+ private const string ApiKeyPlaceholder = "YOUR-API-KEY-HERE";
+
+ private static string GetApiKey() => Environment.GetEnvironmentVariable("UPDOWN_API_KEY") ?? ApiKeyPlaceholder;
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions()
{
@@ -27,9 +34,10 @@ public StatusPageManualTests()
_logger = LoggerFactory.CreateLogger();
}
- [TestCase(ApiKey), Explicit]
- public async Task StatusPages(string apiKey)
+ [Test, Explicit]
+ public async Task StatusPages()
{
+ var apiKey = GetApiKey();
var client = UpdownClientFactory.Create(apiKey);
var results = await client.StatusPages();
var result = results.FirstOrDefault();
@@ -40,9 +48,10 @@ public async Task StatusPages(string apiKey)
Assert.That(results, Is.Not.Null);
}
- [TestCase(ApiKey), Explicit]
- public async Task StatusPageCreateUpdateDelete(string apiKey)
+ [Test, Explicit]
+ public async Task StatusPageCreateUpdateDelete()
{
+ var apiKey = GetApiKey();
const string url = "https://i-am-a-test.com";
var parameters = new StatusPageParameters
{
diff --git a/test/UpdownDotNetTests/StatusPages/StatusPageTests.cs b/test/UpdownDotNetTests/StatusPages/StatusPageTests.cs
index 8c52562..fe32a05 100644
--- a/test/UpdownDotNetTests/StatusPages/StatusPageTests.cs
+++ b/test/UpdownDotNetTests/StatusPages/StatusPageTests.cs
@@ -86,7 +86,7 @@ public void StatusPageCreateNotFound()
.WithNotFound());
var client = UpdownClientFactory.Create(Server.CreateClient());
- Assert.ThrowsAsync(() => client.StatusPageCreate(mockParameters));
+ Assert.ThrowsAsync(() => client.StatusPageCreate(mockParameters));
}
[Test]
@@ -122,7 +122,7 @@ public void StatusPageDeleteNotFound()
.WithNotFound());
var client = UpdownClientFactory.Create(Server.CreateClient());
- Assert.ThrowsAsync(() => client.StatusPageDelete(mockInput.Token));
+ Assert.ThrowsAsync(() => client.StatusPageDelete(mockInput.Token!));
}
[Test]
@@ -159,7 +159,7 @@ public void StatusPageUpdateNotFound()
var client = UpdownClientFactory.Create(Server.CreateClient());
- Assert.ThrowsAsync(() => client.StatusPageUpdate("token", mockParameters));
+ Assert.ThrowsAsync(() => client.StatusPageUpdate("token", mockParameters));
}
}
}
diff --git a/test/UpdownDotNetTests/UpdownDotNetTests.csproj b/test/UpdownDotNetTests/UpdownDotNetTests.csproj
index 6e4b2b6..d74a721 100644
--- a/test/UpdownDotNetTests/UpdownDotNetTests.csproj
+++ b/test/UpdownDotNetTests/UpdownDotNetTests.csproj
@@ -1,9 +1,9 @@
- net8.0;net6.0
+ net9.0;net8.0;net6.0
disable
- disable
+ enable
false
true
@@ -14,6 +14,7 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+