diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e078629..e8421c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,74 +1,14 @@ name: CI on: - pull_request_review: - types: [submitted] workflow_dispatch: permissions: contents: read - pull-requests: read jobs: - check-approval: - name: Check All Reviews Approved - if: > - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request_review' && github.event.review.state == 'approved') - runs-on: ubuntu-latest - outputs: - approved: ${{ steps.check.outputs.approved || steps.dispatch.outputs.approved }} - steps: - - id: check - if: github.event_name == 'pull_request_review' - uses: actions/github-script@v7 - with: - script: | - const pr = context.payload.pull_request; - - // Get the current PR to check for pending review requests - const { data: currentPr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - }); - - // If there are still pending review requests, not everyone has approved - if (currentPr.requested_reviewers.length > 0 || currentPr.requested_teams.length > 0) { - core.setOutput('approved', 'false'); - core.info(`PR has ${currentPr.requested_reviewers.length} pending reviewer(s) and ${currentPr.requested_teams.length} pending team(s).`); - return; - } - - // Get all reviews - const { data: reviews } = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - }); - - // Get latest review state per reviewer (excluding the PR author and COMMENTED-only) - const latestByReviewer = new Map(); - for (const review of reviews) { - if (review.user.login === currentPr.user.login) continue; - if (review.state === 'COMMENTED') continue; - latestByReviewer.set(review.user.login, review.state); - } - - // All latest non-comment reviews must be APPROVED and there must be at least one - const states = [...latestByReviewer.values()]; - const allApproved = states.length > 0 && states.every(s => s === 'APPROVED'); - core.setOutput('approved', allApproved ? 'true' : 'false'); - core.info(`Reviews: ${JSON.stringify(Object.fromEntries(latestByReviewer))} → allApproved=${allApproved}`); - - - id: dispatch - if: github.event_name == 'workflow_dispatch' - run: echo "approved=true" >> "$GITHUB_OUTPUT" - build: name: Build - needs: check-approval - if: needs.check-approval.outputs.approved == 'true' runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index cc918ca..99f41fe 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -1,8 +1,6 @@ name: Integration & Live Schema Tests on: - pull_request_review: - types: [submitted] workflow_dispatch: inputs: run_live_schema: @@ -18,71 +16,11 @@ on: permissions: contents: read - pull-requests: read jobs: - check-approval: - name: Check All Reviews Approved - if: > - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request_review' && github.event.review.state == 'approved') - runs-on: ubuntu-latest - outputs: - approved: ${{ steps.check.outputs.approved || steps.dispatch.outputs.approved }} - steps: - - id: check - if: github.event_name == 'pull_request_review' - uses: actions/github-script@v7 - with: - script: | - const pr = context.payload.pull_request; - - // Get the current PR to check for pending review requests - const { data: currentPr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - }); - - // If there are still pending review requests, not everyone has approved - if (currentPr.requested_reviewers.length > 0 || currentPr.requested_teams.length > 0) { - core.setOutput('approved', 'false'); - core.info(`PR has ${currentPr.requested_reviewers.length} pending reviewer(s) and ${currentPr.requested_teams.length} pending team(s).`); - return; - } - - // Get all reviews - const { data: reviews } = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - }); - - // Get latest review state per reviewer (excluding the PR author and COMMENTED-only) - const latestByReviewer = new Map(); - for (const review of reviews) { - if (review.user.login === currentPr.user.login) continue; - if (review.state === 'COMMENTED') continue; - latestByReviewer.set(review.user.login, review.state); - } - - // All latest non-comment reviews must be APPROVED and there must be at least one - const states = [...latestByReviewer.values()]; - const allApproved = states.length > 0 && states.every(s => s === 'APPROVED'); - core.setOutput('approved', allApproved ? 'true' : 'false'); - core.info(`Reviews: ${JSON.stringify(Object.fromEntries(latestByReviewer))} → allApproved=${allApproved}`); - - - id: dispatch - if: github.event_name == 'workflow_dispatch' - run: echo "approved=true" >> "$GITHUB_OUTPUT" - integration-tests: name: Integration Tests - needs: check-approval - if: > - needs.check-approval.outputs.approved == 'true' && - (github.event_name == 'workflow_dispatch' && inputs.run_integration == true || - github.event_name == 'pull_request_review') + if: inputs.run_integration == true runs-on: ubuntu-latest environment: testing steps: @@ -121,11 +59,7 @@ jobs: live-schema-validation: name: Live Schema Validation - needs: check-approval - if: > - needs.check-approval.outputs.approved == 'true' && - (github.event_name == 'workflow_dispatch' && inputs.run_live_schema == true || - github.event_name == 'pull_request_review') + if: inputs.run_live_schema == true runs-on: ubuntu-latest environment: testing steps: diff --git a/README.md b/README.md index 575d0e1..0222c85 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,54 @@ catch (TorBoxException ex) } ``` +## Standalone Usage + +For console apps, scripts, or scenarios where dependency injection is not needed, create the client directly: + +```csharp +using TorBoxSDK; +using TorBoxSDK.Models.Common; +using TorBoxSDK.Models.User; + +string apiKey = Environment.GetEnvironmentVariable("TORBOX_API_KEY") + ?? throw new InvalidOperationException("Set the TORBOX_API_KEY environment variable."); + +using TorBoxClient client = new(apiKey); +using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); + +try +{ + TorBoxResponse me = await client.Main.User.GetMeAsync(cancellationToken: cts.Token); + Console.WriteLine($"Authenticated as: {me.Data?.Email}"); +} +catch (TorBoxException ex) +{ + Console.Error.WriteLine($"API error [{ex.ErrorCode}]: {ex.Detail ?? ex.Message}"); +} +``` + +For more control over options: + +```csharp +using TorBoxClient client = new(new TorBoxClientOptions +{ + ApiKey = apiKey, + Timeout = TimeSpan.FromSeconds(60) +}); +``` + +Or use the builder pattern: + +```csharp +using TorBoxClient client = new(options => +{ + options.ApiKey = apiKey; + options.Timeout = TimeSpan.FromSeconds(60); +}); +``` + +`TorBoxClient` implements `IDisposable`. Always use a `using` statement to ensure HTTP resources are released. In DI mode, the container manages disposal automatically. + ## Client Hierarchy The SDK is structured around a single root client with three API families: diff --git a/docs/architecture.md b/docs/architecture.md index 0773847..9a7eea2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,7 +46,7 @@ flowchart TD - **Search API**: search-oriented endpoints for torrents, usenet, metadata, Torznab, and Newznab - **Relay API**: relay status and inactivity checks -## DI and instantiation +## Instantiation `AddTorBox()` registers only `ITorBoxClient` in the DI container. All sub-clients (`MainApiClient`, `SearchApiClient`, `RelayApiClient`, and resource clients like `TorrentsClient`) are `internal` and instantiated by `TorBoxClient` itself. They are **not** individually resolvable from the service provider. @@ -62,6 +62,28 @@ provider.GetService(); // null provider.GetService(); // null ``` +`TorBoxClient` also supports standalone instantiation when dependency injection is not needed: + +```csharp +using TorBoxClient client = new("your-api-key"); + +using TorBoxClient configuredClient = new(new TorBoxClientOptions +{ + ApiKey = "your-api-key", + Timeout = TimeSpan.FromSeconds(60) +}); + +using TorBoxClient builtClient = new(options => +{ + options.ApiKey = "your-api-key"; + options.Timeout = TimeSpan.FromSeconds(60); +}); +``` + +The DI-focused constructor is marked with `[ActivatorUtilitiesConstructor]` so ASP.NET Core and other `Microsoft.Extensions.DependencyInjection` consumers choose the `IHttpClientFactory` + `IOptions` path automatically when resolving `ITorBoxClient`. + +`TorBoxClient` implements `IDisposable`. In standalone mode, it owns and disposes the underlying `HttpClient` instances, so it should be wrapped in a `using` statement. In DI mode, `Dispose()` is a no-op because the container manages the HTTP client lifecycle. + ## Cross-cutting behavior - Authentication uses a Bearer token attached by an internal `DelegatingHandler` diff --git a/docs/configuration.md b/docs/configuration.md index e723092..810fdd2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,9 +7,12 @@ TorBoxSDK is configured through `TorBoxClientOptions`. | Property | Required | Default | Notes | |---|---|---|---| | `ApiKey` | Yes | — | Required for authenticated TorBox requests | -| `MainApiBaseUrl` | No | `https://api.torbox.app/v1/api/` | Trailing slash should be preserved | +| `MainApiBaseUrl` | No | `https://api.torbox.app/` | Host URL for the Main API. Trailing slash should be preserved. | +| `ApiVersion` | Yes | `v1` | Version segment used to compute versioned Main and Relay API URLs | +| `MainApiVersionedUrl` | — | Computed | Full Main API URL with version (e.g. `https://api.torbox.app/v1/api/`). Read-only. | | `SearchApiBaseUrl` | No | `https://search-api.torbox.app/` | Trailing slash should be preserved | -| `RelayApiBaseUrl` | No | `https://relay.torbox.app/` | Trailing slash should be preserved | +| `RelayApiBaseUrl` | No | `https://relay.torbox.app/` | Host URL for the Relay API. Trailing slash should be preserved. | +| `RelayApiVersionedUrl` | — | Computed | Full Relay API URL with version (e.g. `https://relay.torbox.app/v1/`). Read-only. | | `Timeout` | No | `00:00:30` | Applied to all configured `HttpClient` instances | ## Configure with code @@ -43,6 +46,33 @@ The SDK binds from the `TorBox` section: } ``` +## Configure without DI + +When using standalone mode, pass options directly to the constructor: + +```csharp +// API key only (default settings) +using TorBoxClient client = new("your-api-key"); + +// Full options object +using TorBoxClient client = new(new TorBoxClientOptions +{ + ApiKey = "your-api-key", + MainApiBaseUrl = "https://api.torbox.app/", + ApiVersion = "v1", + SearchApiBaseUrl = "https://search-api.torbox.app/", + RelayApiBaseUrl = "https://relay.torbox.app/", + Timeout = TimeSpan.FromSeconds(60) +}); + +// Configuration delegate +using TorBoxClient client = new(options => +{ + options.ApiKey = "your-api-key"; + options.Timeout = TimeSpan.FromMinutes(2); +}); +``` + ## Registration overloads - `AddTorBox(Action)` diff --git a/docs/getting-started.md b/docs/getting-started.md index 9f06ed9..2dc3cab 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -27,6 +27,34 @@ using ServiceProvider provider = services.BuildServiceProvider(); ITorBoxClient client = provider.GetRequiredService(); ``` +## Use without dependency injection + +For console apps, scripts, or environments without a DI container, create the client directly: + +```csharp +using TorBoxSDK; + +string apiKey = Environment.GetEnvironmentVariable("TORBOX_API_KEY") + ?? throw new InvalidOperationException("Set the TORBOX_API_KEY environment variable."); + +using TorBoxClient client = new(apiKey); +``` + +You can also pass a `TorBoxClientOptions` instance or a configuration delegate: + +```csharp +using TorBoxClient client = new(new TorBoxClientOptions +{ + ApiKey = apiKey, + Timeout = TimeSpan.FromSeconds(60) +}); +``` + +`TorBoxClient` implements `IDisposable`. Always use a `using` statement to ensure HTTP clients are properly released. In DI mode, the container manages the lifecycle automatically. + +> **When to choose standalone vs DI?** +> Use standalone for simple console tools, scripts, and one-off programs. Use DI for ASP.NET Core apps, hosted services, and anything with `IServiceCollection`. + ## Make your first requests ```csharp diff --git a/src/TorBoxSDK.Examples/GettingStarted/StandaloneSetupExample.cs b/src/TorBoxSDK.Examples/GettingStarted/StandaloneSetupExample.cs new file mode 100644 index 0000000..1ea710d --- /dev/null +++ b/src/TorBoxSDK.Examples/GettingStarted/StandaloneSetupExample.cs @@ -0,0 +1,115 @@ +using TorBoxSDK.Examples.Helpers; +using TorBoxSDK.Models.Common; +using TorBoxSDK.Models.User; + +namespace TorBoxSDK.Examples.GettingStarted; + +/// +/// Demonstrates standalone TorBoxClient usage without dependency injection. +/// Shows all three constructor patterns and IDisposable usage. +/// +public static class StandaloneSetupExample +{ + public static async Task RunAsync() + { + ExampleHelper.PrintHeader("Getting Started — Standalone Setup (No DI)"); + + // Always read API keys from environment variables — never hardcode them. + string apiKey = Environment.GetEnvironmentVariable("TORBOX_API_KEY") + ?? throw new InvalidOperationException( + "Set the TORBOX_API_KEY environment variable."); + + // ────────────────────────────────────────────────────────── + // Pattern 1: Simple constructor with API key only. + // Uses default options (30s timeout, standard URLs). + // ────────────────────────────────────────────────────────── + Console.WriteLine("── Pattern 1: Simple API key constructor ──"); + + using (TorBoxClient client = new(apiKey)) + { + using CancellationTokenSource cts = ExampleHelper.CreateTimeout(); + + try + { + TorBoxResponse response = + await client.Main.User.GetMeAsync(cancellationToken: cts.Token); + + if (response.Data is not null) + { + Console.WriteLine($" Authenticated as: {response.Data.Email}"); + Console.WriteLine($" Plan: {response.Data.Plan}"); + } + } + catch (TorBoxException ex) + { + Console.Error.WriteLine($" API error [{ex.ErrorCode}]: {ex.Detail ?? ex.Message}"); + } + } + + Console.WriteLine(); + + // ────────────────────────────────────────────────────────── + // Pattern 2: Options object for full control. + // ────────────────────────────────────────────────────────── + Console.WriteLine("── Pattern 2: Options object constructor ──"); + + using (TorBoxClient client = new(new TorBoxClientOptions + { + ApiKey = apiKey, + Timeout = TimeSpan.FromSeconds(60) + })) + { + using CancellationTokenSource cts = ExampleHelper.CreateTimeout(timeoutSeconds: 60); + + try + { + TorBoxResponse response = + await client.Main.User.GetMeAsync(cancellationToken: cts.Token); + + if (response.Data is not null) + { + Console.WriteLine($" Authenticated as: {response.Data.Email}"); + } + } + catch (TorBoxException ex) + { + Console.Error.WriteLine($" API error [{ex.ErrorCode}]: {ex.Detail ?? ex.Message}"); + } + } + + Console.WriteLine(); + + // ────────────────────────────────────────────────────────── + // Pattern 3: Configuration delegate (builder pattern). + // ────────────────────────────────────────────────────────── + Console.WriteLine("── Pattern 3: Configuration delegate ──"); + + using (TorBoxClient client = new(options => + { + options.ApiKey = apiKey; + options.Timeout = TimeSpan.FromSeconds(45); + })) + { + using CancellationTokenSource cts = ExampleHelper.CreateTimeout(timeoutSeconds: 45); + + try + { + TorBoxResponse response = + await client.Main.User.GetMeAsync(cancellationToken: cts.Token); + + if (response.Data is not null) + { + Console.WriteLine($" Authenticated as: {response.Data.Email}"); + } + } + catch (TorBoxException ex) + { + Console.Error.WriteLine($" API error [{ex.ErrorCode}]: {ex.Detail ?? ex.Message}"); + } + } + + Console.WriteLine(); + Console.WriteLine("Standalone setup example completed."); + Console.WriteLine("Note: TorBoxClient implements IDisposable — always use 'using' statements."); + } +} diff --git a/src/TorBoxSDK.Examples/Program.cs b/src/TorBoxSDK.Examples/Program.cs index 2f63cc3..26b49b5 100644 --- a/src/TorBoxSDK.Examples/Program.cs +++ b/src/TorBoxSDK.Examples/Program.cs @@ -23,77 +23,78 @@ ║ Getting Started ║ ║ 1. Basic DI Setup ║ ║ 2. Configuration from appsettings.json ║ + ║ 3. Standalone Setup (No DI) ║ ║ ║ ║ ── Main Client ───────────────────────────────────── ║ ║ ║ ║ Main > Torrents ║ - ║ 3. List Torrents ║ - ║ 4. Create Torrent ║ - ║ 5. Control Torrent (pause/resume/delete) ║ - ║ 6. Download Torrent ║ - ║ 7. Check Cached ║ - ║ 8. Edit Torrent, Info by File & Magnet-to-File ║ + ║ 4. List Torrents ║ + ║ 5. Create Torrent ║ + ║ 6. Control Torrent (pause/resume/delete) ║ + ║ 7. Download Torrent ║ + ║ 8. Check Cached ║ + ║ 9. Edit Torrent, Info by File & Magnet-to-File ║ ║ ║ ║ Main > Usenet ║ - ║ 9. List Usenet Downloads ║ - ║ 10. Create Usenet Download ║ - ║ 11. Usenet Advanced (Cache, Edit, Async Create) ║ + ║ 10. List Usenet Downloads ║ + ║ 11. Create Usenet Download ║ + ║ 12. Usenet Advanced (Cache, Edit, Async Create) ║ ║ ║ ║ Main > Web Downloads ║ - ║ 12. List Web Downloads ║ - ║ 13. Create Web Download & Hosters ║ - ║ 14. Web Downloads Advanced (Cache, Edit, Async) ║ + ║ 13. List Web Downloads ║ + ║ 14. Create Web Download & Hosters ║ + ║ 15. Web Downloads Advanced (Cache, Edit, Async) ║ ║ ║ ║ Main > User ║ - ║ 15. Profile & Account Info ║ - ║ 16. Manage Settings ║ - ║ 17. Authentication & Device Auth ║ - ║ 18. Search Engines Management ║ - ║ 19. Transactions & PDF Export ║ + ║ 16. Profile & Account Info ║ + ║ 17. Manage Settings ║ + ║ 18. Authentication & Device Auth ║ + ║ 19. Search Engines Management ║ + ║ 20. Transactions & PDF Export ║ ║ ║ ║ Main > Notifications ║ - ║ 20. Manage Notifications ║ + ║ 21. Manage Notifications ║ ║ ║ ║ Main > RSS ║ - ║ 21. RSS Feeds ║ + ║ 22. RSS Feeds ║ ║ ║ ║ Main > Integrations ║ - ║ 22. Cloud Storage Integration (Google Drive) ║ - ║ 23. All Cloud Providers (Dropbox, OneDrive, etc.) ║ - ║ 24. OAuth & Discord Integration ║ - ║ 25. Job Management ║ + ║ 23. Cloud Storage Integration (Google Drive) ║ + ║ 24. All Cloud Providers (Dropbox, OneDrive, etc.) ║ + ║ 25. OAuth & Discord Integration ║ + ║ 26. Job Management ║ ║ ║ ║ Main > Vendors ║ - ║ 26. Vendor Account Management ║ + ║ 27. Vendor Account Management ║ ║ ║ ║ Main > Queued ║ - ║ 27. Queued Downloads ║ + ║ 28. Queued Downloads ║ ║ ║ ║ Main > Stream ║ - ║ 28. Stream Media Files ║ + ║ 29. Stream Media Files ║ ║ ║ ║ Main > General ║ - ║ 29. Service Status & Stats ║ - ║ 30. Speedtest Files ║ + ║ 30. Service Status & Stats ║ + ║ 31. Speedtest Files ║ ║ ║ ║ ── Search Client ─────────────────────────────────── ║ ║ ║ ║ Search ║ - ║ 31. Search Torrents ║ - ║ 32. Search Usenet ║ - ║ 33. Search Meta (Movies, TV) ║ - ║ 34. Search Tutorials ║ - ║ 35. Download Search Results & Get by ID ║ + ║ 32. Search Torrents ║ + ║ 33. Search Usenet ║ + ║ 34. Search Meta (Movies, TV) ║ + ║ 35. Search Tutorials ║ + ║ 36. Download Search Results & Get by ID ║ ║ ║ ║ ── Relay Client ──────────────────────────────────── ║ ║ ║ ║ Relay ║ - ║ 36. Relay Status & Inactivity Check ║ + ║ 37. Relay Status & Inactivity Check ║ ║ ║ ║ ── Other ─────────────────────────────────────────── ║ ║ ║ ║ Error Handling ║ - ║ 37. Comprehensive Error Handling Patterns ║ + ║ 38. Comprehensive Error Handling Patterns ║ ║ ║ ║ 0. Exit ║ ║ ║ @@ -102,12 +103,12 @@ while (true) { - Console.Write("Select an example (0-37): "); + Console.Write("Select an example (0-38): "); string? input = Console.ReadLine(); if (!int.TryParse(input, out int choice)) { - Console.WriteLine("Invalid input. Please enter a number between 0 and 37."); + Console.WriteLine("Invalid input. Please enter a number between 0 and 38."); continue; } @@ -117,9 +118,9 @@ break; } - if (choice is < 1 or > 37) + if (choice is < 1 or > 38) { - Console.WriteLine("Invalid choice. Please enter a number between 0 and 37."); + Console.WriteLine("Invalid choice. Please enter a number between 0 and 38."); continue; } @@ -130,69 +131,70 @@ // Getting Started 1 => BasicSetupExample.RunAsync(), 2 => ConfigurationExample.RunAsync(), + 3 => StandaloneSetupExample.RunAsync(), // Main > Torrents - 3 => ListTorrentsExample.RunAsync(), - 4 => CreateTorrentExample.RunAsync(), - 5 => ControlTorrentExample.RunAsync(), - 6 => DownloadTorrentExample.RunAsync(), - 7 => CheckCachedExample.RunAsync(), - 8 => EditTorrentExample.RunAsync(), + 4 => ListTorrentsExample.RunAsync(), + 5 => CreateTorrentExample.RunAsync(), + 6 => ControlTorrentExample.RunAsync(), + 7 => DownloadTorrentExample.RunAsync(), + 8 => CheckCachedExample.RunAsync(), + 9 => EditTorrentExample.RunAsync(), // Main > Usenet - 9 => ListUsenetExample.RunAsync(), - 10 => CreateUsenetExample.RunAsync(), - 11 => UsenetAdvancedExample.RunAsync(), + 10 => ListUsenetExample.RunAsync(), + 11 => CreateUsenetExample.RunAsync(), + 12 => UsenetAdvancedExample.RunAsync(), // Main > Web Downloads - 12 => ListWebDownloadsExample.RunAsync(), - 13 => CreateWebDownloadExample.RunAsync(), - 14 => WebDownloadsAdvancedExample.RunAsync(), + 13 => ListWebDownloadsExample.RunAsync(), + 14 => CreateWebDownloadExample.RunAsync(), + 15 => WebDownloadsAdvancedExample.RunAsync(), // Main > User - 15 => GetProfileExample.RunAsync(), - 16 => ManageSettingsExample.RunAsync(), - 17 => AuthenticationExample.RunAsync(), - 18 => SearchEnginesExample.RunAsync(), - 19 => TransactionsExample.RunAsync(), + 16 => GetProfileExample.RunAsync(), + 17 => ManageSettingsExample.RunAsync(), + 18 => AuthenticationExample.RunAsync(), + 19 => SearchEnginesExample.RunAsync(), + 20 => TransactionsExample.RunAsync(), // Main > Notifications - 20 => NotificationsExample.RunAsync(), + 21 => NotificationsExample.RunAsync(), // Main > RSS - 21 => RssFeedsExample.RunAsync(), + 22 => RssFeedsExample.RunAsync(), // Main > Integrations - 22 => CloudIntegrationExample.RunAsync(), - 23 => AllCloudProvidersExample.RunAsync(), - 24 => OAuthExample.RunAsync(), - 25 => JobManagementExample.RunAsync(), + 23 => CloudIntegrationExample.RunAsync(), + 24 => AllCloudProvidersExample.RunAsync(), + 25 => OAuthExample.RunAsync(), + 26 => JobManagementExample.RunAsync(), // Main > Vendors - 26 => VendorExample.RunAsync(), + 27 => VendorExample.RunAsync(), // Main > Queued - 27 => QueuedDownloadsExample.RunAsync(), + 28 => QueuedDownloadsExample.RunAsync(), // Main > Stream - 28 => StreamExample.RunAsync(), + 29 => StreamExample.RunAsync(), // Main > General - 29 => GeneralExample.RunAsync(), - 30 => SpeedtestExample.RunAsync(), + 30 => GeneralExample.RunAsync(), + 31 => SpeedtestExample.RunAsync(), // Search - 31 => SearchTorrentsExample.RunAsync(), - 32 => SearchUsenetExample.RunAsync(), - 33 => SearchMetaExample.RunAsync(), - 34 => SearchTutorialsExample.RunAsync(), - 35 => DownloadSearchResultsExample.RunAsync(), + 32 => SearchTorrentsExample.RunAsync(), + 33 => SearchUsenetExample.RunAsync(), + 34 => SearchMetaExample.RunAsync(), + 35 => SearchTutorialsExample.RunAsync(), + 36 => DownloadSearchResultsExample.RunAsync(), // Relay - 36 => RelayExample.RunAsync(), + 37 => RelayExample.RunAsync(), // Error Handling - 37 => ErrorHandlingExample.RunAsync(), + 38 => ErrorHandlingExample.RunAsync(), _ => Task.CompletedTask, }); diff --git a/src/TorBoxSDK/DependencyInjection/TorBoxServiceCollectionExtensions.cs b/src/TorBoxSDK/DependencyInjection/TorBoxServiceCollectionExtensions.cs index c80d608..4eb63c2 100644 --- a/src/TorBoxSDK/DependencyInjection/TorBoxServiceCollectionExtensions.cs +++ b/src/TorBoxSDK/DependencyInjection/TorBoxServiceCollectionExtensions.cs @@ -98,11 +98,8 @@ private static void RegisterCore(IServiceCollection services) .AddHttpMessageHandler(); // --- Only ITorBoxClient / TorBoxClient is registered in the DI container --- - services.AddScoped(sp => - { - IHttpClientFactory httpClientFactory = sp.GetRequiredService(); - IOptions options = sp.GetRequiredService>(); - return new TorBoxClient(httpClientFactory, options); - }); + // The container resolves the (IHttpClientFactory, IOptions) constructor + // automatically via [ActivatorUtilitiesConstructor]. + services.AddScoped(); } } diff --git a/src/TorBoxSDK/Http/Handlers/AuthHandler.cs b/src/TorBoxSDK/Http/Handlers/AuthHandler.cs index bfe5b83..7522b8c 100644 --- a/src/TorBoxSDK/Http/Handlers/AuthHandler.cs +++ b/src/TorBoxSDK/Http/Handlers/AuthHandler.cs @@ -1,4 +1,5 @@ using System.Net.Http.Headers; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using TorBoxSDK.Http.Validation; @@ -8,24 +9,40 @@ namespace TorBoxSDK.Http.Handlers; /// HTTP message handler that injects the TorBox API key as a Bearer token /// into the Authorization header of every outgoing request. /// -/// -/// Initializes a new instance of the class. -/// -/// The options containing the API key. -internal sealed class AuthHandler(IOptions options) : DelegatingHandler +internal sealed class AuthHandler : DelegatingHandler { - private readonly IOptions _options = Guard.ThrowIfNull(options); + private readonly string _apiKey; + + /// + /// Initializes a new instance of the class + /// for standalone usage with a direct API key. + /// + /// The TorBox API key. + internal AuthHandler(string apiKey) + { + _apiKey = apiKey ?? string.Empty; + } + + /// + /// Initializes a new instance of the class + /// for DI usage with . + /// + /// The options containing the API key. + [ActivatorUtilitiesConstructor] + public AuthHandler(IOptions options) + { + Guard.ThrowIfNull(options); + _apiKey = options.Value.ApiKey; + } /// protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { - string apiKey = _options.Value.ApiKey; - - if (!string.IsNullOrWhiteSpace(apiKey)) + if (!string.IsNullOrWhiteSpace(_apiKey)) { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); } return base.SendAsync(request, cancellationToken); diff --git a/src/TorBoxSDK/ITorBoxClient.cs b/src/TorBoxSDK/ITorBoxClient.cs index 6037a63..a592397 100644 --- a/src/TorBoxSDK/ITorBoxClient.cs +++ b/src/TorBoxSDK/ITorBoxClient.cs @@ -11,7 +11,7 @@ namespace TorBoxSDK; /// Provides access to the Main, Search, and Relay API clients /// that together cover the full TorBox platform surface. /// -public interface ITorBoxClient +public interface ITorBoxClient : IDisposable { /// /// Gets the Main API client, which exposes resource clients for diff --git a/src/TorBoxSDK/TorBoxClient.cs b/src/TorBoxSDK/TorBoxClient.cs index 09b6417..971963d 100644 --- a/src/TorBoxSDK/TorBoxClient.cs +++ b/src/TorBoxSDK/TorBoxClient.cs @@ -1,5 +1,7 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using TorBoxSDK.Http; +using TorBoxSDK.Http.Handlers; using TorBoxSDK.Http.Validation; using TorBoxSDK.Main; using TorBoxSDK.Relay; @@ -20,14 +22,23 @@ namespace TorBoxSDK; /// to access all SDK functionality. /// /// -/// Register the client through dependency injection using +/// The client can be used standalone (via new TorBoxClient(apiKey)) or +/// through dependency injection using /// . -/// This class cannot be instantiated directly — it is created internally -/// by the DI container. +/// +/// +/// In standalone mode, the client owns its instances +/// and must be disposed when no longer needed (preferably with a using statement). +/// In DI mode, the container manages the lifecycle and is a no-op. /// /// public sealed class TorBoxClient : ITorBoxClient { + private readonly HttpClient? _ownedMainHttpClient; + private readonly HttpClient? _ownedSearchHttpClient; + private readonly HttpClient? _ownedRelayHttpClient; + private bool _disposed; + /// public IMainApiClient Main { get; } @@ -37,16 +48,79 @@ public sealed class TorBoxClient : ITorBoxClient /// public IRelayApiClient Relay { get; } + /// + /// Initializes a new standalone instance of the class + /// using the specified API key and default options. + /// + /// The TorBox API key used for Bearer authentication. + /// + /// Thrown when is . + /// + /// + /// Thrown when is empty. + /// + public TorBoxClient(string apiKey) + : this(new TorBoxClientOptions { ApiKey = apiKey }) + { + } + + /// + /// Initializes a new standalone instance of the class + /// using the specified options. + /// + /// The SDK configuration options. + /// + /// Thrown when is . + /// + /// + /// Thrown when is or empty, + /// or when a base URL is not a valid absolute URI. + /// + public TorBoxClient(TorBoxClientOptions options) + { + Guard.ThrowIfNull(options); + Guard.ThrowIfNullOrEmpty(options.ApiKey, nameof(options.ApiKey)); + ValidateBaseUrl(options.MainApiVersionedUrl, nameof(options.MainApiBaseUrl)); + ValidateBaseUrl(options.SearchApiBaseUrl, nameof(options.SearchApiBaseUrl)); + ValidateBaseUrl(options.RelayApiVersionedUrl, nameof(options.RelayApiBaseUrl)); + + _ownedMainHttpClient = CreateHttpClient(options.ApiKey, options.MainApiVersionedUrl, options.Timeout); + _ownedSearchHttpClient = CreateHttpClient(options.ApiKey, options.SearchApiBaseUrl, options.Timeout); + _ownedRelayHttpClient = CreateHttpClient(options.ApiKey, options.RelayApiVersionedUrl, options.Timeout); + + Main = new MainApiClient(_ownedMainHttpClient, options.ApiKey, options.MainApiBaseUrl); + Search = new SearchApiClient(_ownedSearchHttpClient); + Relay = new RelayApiClient(_ownedRelayHttpClient, options.RelayApiBaseUrl); + } + + /// + /// Initializes a new standalone instance of the class + /// using a configuration delegate. + /// + /// A delegate to configure . + /// + /// Thrown when is . + /// + /// + /// Thrown when the configured is or empty. + /// + public TorBoxClient(Action configure) + : this(ApplyConfigure(configure)) + { + } + /// /// Initializes a new instance of the class /// using an to create the required HTTP clients. + /// This constructor is intended for DI usage. /// /// The HTTP client factory used to create named clients. /// The SDK configuration options. /// /// Thrown when or is . /// - internal TorBoxClient(IHttpClientFactory httpClientFactory, IOptions options) + [ActivatorUtilitiesConstructor] + public TorBoxClient(IHttpClientFactory httpClientFactory, IOptions options) { Guard.ThrowIfNull(httpClientFactory); Guard.ThrowIfNull(options); @@ -59,4 +133,53 @@ internal TorBoxClient(IHttpClientFactory httpClientFactory, IOptions + /// Releases the HTTP clients owned by this instance (standalone mode only). + /// In DI mode, this method is a no-op because the container manages the HTTP client lifecycle. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _ownedMainHttpClient?.Dispose(); + _ownedSearchHttpClient?.Dispose(); + _ownedRelayHttpClient?.Dispose(); + _disposed = true; + } + + private static HttpClient CreateHttpClient(string apiKey, string baseUrl, TimeSpan timeout) + { + var authHandler = new AuthHandler(apiKey) { InnerHandler = new HttpClientHandler() }; + + return new HttpClient(authHandler) + { + BaseAddress = new Uri(baseUrl), + Timeout = timeout + }; + } + + private static TorBoxClientOptions ApplyConfigure(Action configure) + { + Guard.ThrowIfNull(configure); + TorBoxClientOptions options = new(); + configure(options); + return options; + } + + private static void ValidateBaseUrl(string url, string paramName) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out _)) + { + throw new ArgumentException($"'{url}' is not a valid absolute URI.", paramName); + } + + if (!url.EndsWith('/')) + { + throw new ArgumentException($"Base URL '{url}' must end with a trailing '/' for correct relative URI resolution.", paramName); + } + } } diff --git a/src/TorBoxSDK/TorBoxClientOptions.cs b/src/TorBoxSDK/TorBoxClientOptions.cs index d61be49..48643ab 100644 --- a/src/TorBoxSDK/TorBoxClientOptions.cs +++ b/src/TorBoxSDK/TorBoxClientOptions.cs @@ -44,7 +44,11 @@ public sealed class TorBoxClientOptions /// /// Gets the fully-qualified Main API base URL including the version path. /// - internal string MainApiVersionedUrl + /// + /// Computed from and . + /// For example, https://api.torbox.app/v1/api/. + /// + public string MainApiVersionedUrl { get { @@ -82,7 +86,11 @@ internal string MainApiVersionedUrl /// /// Gets the fully-qualified Relay API base URL including the version path. /// - internal string RelayApiVersionedUrl + /// + /// Computed from and . + /// For example, https://relay.torbox.app/v1/. + /// + public string RelayApiVersionedUrl { get { diff --git a/tests/TorBoxSDK.IntegrationTests/Standalone/StandaloneClientTests.cs b/tests/TorBoxSDK.IntegrationTests/Standalone/StandaloneClientTests.cs new file mode 100644 index 0000000..0c07d3c --- /dev/null +++ b/tests/TorBoxSDK.IntegrationTests/Standalone/StandaloneClientTests.cs @@ -0,0 +1,58 @@ +using TorBoxSDK.Models.Common; +using TorBoxSDK.Models.User; + +namespace TorBoxSDK.IntegrationTests.Standalone; + +/// +/// Integration tests that verify the standalone +/// constructors work end-to-end against the live TorBox API without +/// requiring a DI container. +/// +/// +/// These tests require the TORBOX_API_KEY environment variable to be set. +/// When it is not available, tests are skipped gracefully via . +/// +[Collection("Integration")] +[Trait("Category", "Integration")] +public sealed class StandaloneClientTests +{ + [SkippableFact] + public async Task StandaloneClient_WithValidApiKey_CanCallApi() + { + // Skip when no API key is available. + string? apiKey = Environment.GetEnvironmentVariable("TORBOX_API_KEY"); + Skip.If(string.IsNullOrEmpty(apiKey), "TORBOX_API_KEY not set."); + + // Arrange + using var client = new TorBoxClient(apiKey!); + using CancellationTokenSource cts = new(TimeSpan.FromMinutes(1)); + + // Act + TorBoxResponse response = await client.Main.User.GetMeAsync( + cancellationToken: cts.Token); + + // Assert + Assert.True(response.Success); + Assert.NotNull(response.Data); + } + + [SkippableFact] + public async Task StandaloneClient_WithOptions_CanCallApi() + { + // Skip when no API key is available. + string? apiKey = Environment.GetEnvironmentVariable("TORBOX_API_KEY"); + Skip.If(string.IsNullOrEmpty(apiKey), "TORBOX_API_KEY not set."); + + // Arrange + using var client = new TorBoxClient(new TorBoxClientOptions { ApiKey = apiKey! }); + using CancellationTokenSource cts = new(TimeSpan.FromMinutes(1)); + + // Act + TorBoxResponse response = await client.Main.User.GetMeAsync( + cancellationToken: cts.Token); + + // Assert + Assert.True(response.Success); + Assert.NotNull(response.Data); + } +} diff --git a/tests/TorboxSDK.UnitTests/Http/AuthHandlerTests.cs b/tests/TorboxSDK.UnitTests/Http/AuthHandlerTests.cs index 9a157c3..b488d0a 100644 --- a/tests/TorboxSDK.UnitTests/Http/AuthHandlerTests.cs +++ b/tests/TorboxSDK.UnitTests/Http/AuthHandlerTests.cs @@ -44,4 +44,40 @@ public async Task SendAsync_WithEmptyApiKey_DoesNotAddAuthorizationHeader() Assert.NotNull(innerHandler.LastRequest); Assert.Null(innerHandler.LastRequest.Headers.Authorization); } + + [Fact] + public async Task SendAsync_WithStringApiKey_SetsAuthorizationHeader() + { + // Arrange + var innerHandler = new MockHttpMessageHandler("""{"success":true,"error":null,"detail":"OK"}"""); + var authHandler = new AuthHandler("test-key") { InnerHandler = innerHandler }; + using var httpClient = new HttpClient(authHandler); + using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.torbox.app/v1/api/torrents/mylist"); + + // Act + await httpClient.SendAsync(request); + + // Assert + Assert.NotNull(innerHandler.LastRequest); + Assert.NotNull(innerHandler.LastRequest.Headers.Authorization); + Assert.Equal("Bearer", innerHandler.LastRequest.Headers.Authorization.Scheme); + Assert.Equal("test-key", innerHandler.LastRequest.Headers.Authorization.Parameter); + } + + [Fact] + public async Task SendAsync_WithEmptyStringApiKey_DoesNotSetAuthorizationHeader() + { + // Arrange + var innerHandler = new MockHttpMessageHandler("""{"success":true,"error":null,"detail":"OK"}"""); + var authHandler = new AuthHandler(string.Empty) { InnerHandler = innerHandler }; + using var httpClient = new HttpClient(authHandler); + using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.torbox.app/v1/api/torrents/mylist"); + + // Act + await httpClient.SendAsync(request); + + // Assert + Assert.NotNull(innerHandler.LastRequest); + Assert.Null(innerHandler.LastRequest.Headers.Authorization); + } } diff --git a/tests/TorboxSDK.UnitTests/TorBoxClientTests.cs b/tests/TorboxSDK.UnitTests/TorBoxClientTests.cs new file mode 100644 index 0000000..a5e4659 --- /dev/null +++ b/tests/TorboxSDK.UnitTests/TorBoxClientTests.cs @@ -0,0 +1,152 @@ +using System.Reflection; +using TorBoxSDK; + +namespace TorboxSDK.UnitTests; + +public sealed class TorBoxClientTests +{ + [Fact] + public void Constructor_WithApiKey_CreatesClientWithAllSubClients() + { + // Arrange + string apiKey = "test-key"; + + // Act + using TorBoxClient client = new(apiKey); + + // Assert + Assert.NotNull(client.Main); + Assert.NotNull(client.Search); + Assert.NotNull(client.Relay); + } + + [Fact] + public void Constructor_WithOptions_CreatesClientWithAllSubClients() + { + // Arrange + TorBoxClientOptions options = new() { ApiKey = "test-key" }; + + // Act + using TorBoxClient client = new(options); + + // Assert + Assert.NotNull(client.Main); + Assert.NotNull(client.Search); + Assert.NotNull(client.Relay); + } + + [Fact] + public void Constructor_WithConfigure_CreatesClientWithAllSubClients() + { + // Arrange + Action configure = opts => opts.ApiKey = "test-key"; + + // Act + using TorBoxClient client = new(configure); + + // Assert + Assert.NotNull(client.Main); + Assert.NotNull(client.Search); + Assert.NotNull(client.Relay); + } + + [Fact] + public void Constructor_WithNullApiKey_ThrowsArgumentNullException() + { + // Arrange + string apiKey = null!; + + // Act & Assert + Assert.Throws(() => new TorBoxClient(apiKey)); + } + + [Fact] + public void Constructor_WithEmptyApiKey_ThrowsArgumentException() + { + // Arrange + string apiKey = string.Empty; + + // Act & Assert + Assert.Throws(() => new TorBoxClient(apiKey)); + } + + [Fact] + public void Constructor_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + TorBoxClientOptions options = null!; + + // Act & Assert + Assert.Throws(() => new TorBoxClient(options)); + } + + [Fact] + public void Constructor_WithNullConfigure_ThrowsArgumentNullException() + { + // Arrange + Action configure = null!; + + // Act & Assert + Assert.Throws(() => new TorBoxClient(configure)); + } + + [Fact] + public void Constructor_WithCustomTimeout_UsesProvidedTimeout() + { + // Arrange + TimeSpan expectedTimeout = TimeSpan.FromSeconds(120); + TorBoxClientOptions options = new() + { + ApiKey = "test-key", + Timeout = expectedTimeout + }; + + // Act + using TorBoxClient client = new(options); + + // Assert — verify owned HttpClient has the configured timeout via reflection + HttpClient? ownedHttpClient = typeof(TorBoxClient) + .GetField("_ownedMainHttpClient", BindingFlags.Instance | BindingFlags.NonPublic) + ?.GetValue(client) as HttpClient; + + Assert.NotNull(ownedHttpClient); + Assert.Equal(expectedTimeout, ownedHttpClient.Timeout); + } + + [Fact] + public void Dispose_WhenStandalone_DisposesOwnedHttpClients() + { + // Arrange + TorBoxClient client = new("test-key"); + + HttpClient? ownedHttpClient = typeof(TorBoxClient) + .GetField("_ownedMainHttpClient", BindingFlags.Instance | BindingFlags.NonPublic) + ?.GetValue(client) as HttpClient; + + Assert.NotNull(ownedHttpClient); + + // Act + client.Dispose(); + + // Assert — a disposed HttpClient throws ObjectDisposedException on use + Assert.Throws(() => + { + using HttpRequestMessage request = new(HttpMethod.Get, "https://example.com"); + ownedHttpClient.Send(request); + }); + } + + [Fact] + public void Dispose_CalledTwice_DoesNotThrow() + { + // Arrange + TorBoxClient client = new("test-key"); + + // Act + client.Dispose(); + + // Assert — second dispose does not throw + Exception? exception = Record.Exception(() => client.Dispose()); + Assert.Null(exception); + } +}