From fdb2dd41ebed47f2f4128ec7d69d4961a414603f Mon Sep 17 00:00:00 2001 From: Andrii Date: Wed, 4 Feb 2026 14:17:05 +0200 Subject: [PATCH 1/4] Implement GetCallbacksBySubscriptionIdAsync - webhook callback event history related to subscription ID with advanced filtering and sorting capabilities --- CHANGELOG.md | 5 + .../GetCallbacksBySubscriptionIdExample.cs | 76 +++++++++++++ ...GetCallbacksBySubscriptionIdServiceTest.cs | 52 +++++++++ .../Services/EventSubscriptionServiceTest.cs | 100 +++++++++++++++++- .../Interfaces/IEventSubscriptionService.cs | 11 ++ .../Service/EventSubscriptionService.cs | 21 ++++ SignNow.props | 2 +- 7 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 SignNow.Net.Examples/Webhooks/GetCallbacksBySubscriptionIdExample.cs create mode 100644 SignNow.Net.Test/AcceptanceTests/GetCallbacksBySubscriptionIdServiceTest.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c48bc1..48463833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](http://semver.org). ## [Unreleased] - TBD +## [1.5.0] - 2026-02-05 +### Added +- Event Subscriptions & Webhooks: + - Get Callbacks History by ID: webhook callback event history related to subscription ID with advanced filtering and sorting capabilities + ## [1.4.0] - 2026-01-13 ### Added - Event Subscriptions & Webhooks: diff --git a/SignNow.Net.Examples/Webhooks/GetCallbacksBySubscriptionIdExample.cs b/SignNow.Net.Examples/Webhooks/GetCallbacksBySubscriptionIdExample.cs new file mode 100644 index 00000000..90375efb --- /dev/null +++ b/SignNow.Net.Examples/Webhooks/GetCallbacksBySubscriptionIdExample.cs @@ -0,0 +1,76 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SignNow.Net.Model.Requests; +using SignNow.Net.Model.Requests.GetFolderQuery; + +namespace SignNow.Net.Examples +{ + [TestClass] + public class GetCallbacksBySubscriptionIdExample : ExamplesBase + { + /// + /// Demonstrates how to get webhook callback events for a specific event subscription. + /// This example shows how to retrieve callback history filtered by subscription ID and analyze webhook delivery results. + /// + /// + [TestMethod] + public async Task GetCallbacksBySubscriptionIdAsync() + { + var subscriptions = await testContext.Events + .GetEventSubscriptionsListAsync() + .ConfigureAwait(false); + + if (subscriptions?.Data == null || subscriptions.Data.Count == 0) + { + Console.WriteLine("No event subscriptions found. Please create an event subscription first."); + return; + } + + var subscriptionId = subscriptions.Data.First().Id; + Console.WriteLine($"Using subscription ID: {subscriptionId}"); + + // Example 1: Get all callbacks for a specific subscription with default options + Console.WriteLine("\n=== Example 1: Get all callbacks for specific subscription ==="); + var allCallbacks = await testContext.Events + .GetCallbacksBySubscriptionIdAsync(subscriptionId) + .ConfigureAwait(false); + + Console.WriteLine($"Total callbacks found for subscription {subscriptionId}: {allCallbacks.Data.Count}"); + Console.WriteLine($"Current page: {allCallbacks.Meta.Pagination.CurrentPage}"); + Console.WriteLine($"Per page: {allCallbacks.Meta.Pagination.PerPage}"); + Console.WriteLine($"Total pages: {allCallbacks.Meta.Pagination.TotalPages}"); + Console.WriteLine($"Total items: {allCallbacks.Meta.Pagination.Total}"); + + // Example 2: Filter successful callbacks for the subscription + Console.WriteLine($"\n=== Example 2: Get successful callbacks for subscription {subscriptionId} ==="); + var successfulCallbacks = await testContext.Events + .GetCallbacksBySubscriptionIdAsync(subscriptionId, new GetCallbacksOptions + { + Filters = f => f.Or( + f => f.Date.Between(DateTime.UtcNow.AddDays(-30), DateTime.UtcNow), + f => f.Code.Between(200, 299), + f => f.CallbackUrl.Like("example") + ), + Sortings = s => s.Code(SortOrder.Descending), + PerPage = 10 + }) + .ConfigureAwait(false); + + Console.WriteLine($"Successful callbacks: {successfulCallbacks.Data.Count}"); + foreach (var callback in successfulCallbacks.Data.Take(3)) + { + Console.WriteLine($" ID: {callback.Id}"); + Console.WriteLine($" Subscription ID: {callback.EventSubscriptionId}"); + Console.WriteLine($" Status Code: {callback.ResponseStatusCode}"); + Console.WriteLine($" Event: {callback.EventName}"); + Console.WriteLine($" Entity ID: {callback.EntityId}"); + Console.WriteLine($" Duration: {callback.Duration:F3}s"); + Console.WriteLine($" Start Time: {callback.RequestStartTime}"); + Console.WriteLine($" Callback Url: {callback.CallbackUrl}"); + Console.WriteLine(); + } + } + } +} diff --git a/SignNow.Net.Test/AcceptanceTests/GetCallbacksBySubscriptionIdServiceTest.cs b/SignNow.Net.Test/AcceptanceTests/GetCallbacksBySubscriptionIdServiceTest.cs new file mode 100644 index 00000000..365a3f4f --- /dev/null +++ b/SignNow.Net.Test/AcceptanceTests/GetCallbacksBySubscriptionIdServiceTest.cs @@ -0,0 +1,52 @@ +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SignNow.Net.Model.Requests; +using SignNow.Net.Model.Requests.GetFolderQuery; +using UnitTests; + +namespace AcceptanceTests +{ + [TestClass] + public class GetCallbacksBySubscriptionIdServiceTest : AuthorizedApiTestBase + { + [TestMethod] + public async Task GetCallbacksBySubscriptionIdAsync_WithFilters_ReturnsFilteredResults() + { + var eventSubscriptions = await SignNowTestContext.Events + .GetEventSubscriptionsListAsync() + .ConfigureAwait(false); + + if (eventSubscriptions?.Data == null || eventSubscriptions.Data.Count == 0) + { + Assert.Inconclusive("No event subscriptions available for testing"); + return; + } + + var subscriptionId = eventSubscriptions.Data[0].Id; + + var options = new GetCallbacksOptions + { + Filters = f => f.Code.Between(200, 599), + Sortings = s => s.StartTime(SortOrder.Descending), + PerPage = 15 + }; + + var response = await SignNowTestContext.Events + .GetCallbacksBySubscriptionIdAsync(subscriptionId, options) + .ConfigureAwait(false); + + Assert.IsNotNull(response); + Assert.IsNotNull(response.Data); + Assert.IsNotNull(response.Meta); + Assert.AreEqual(15, response.Meta.Pagination.PerPage); + + // Verify all callbacks belong to the specified subscription and match filter criteria + foreach (var callback in response.Data) + { + Assert.AreEqual(subscriptionId, callback.EventSubscriptionId); + Assert.IsTrue(callback.ResponseStatusCode >= 200 && callback.ResponseStatusCode <= 299, + $"Callback status code {callback.ResponseStatusCode} should be in range 200-299"); + } + } + } +} diff --git a/SignNow.Net.Test/UnitTests/Services/EventSubscriptionServiceTest.cs b/SignNow.Net.Test/UnitTests/Services/EventSubscriptionServiceTest.cs index fb56fe51..3b9194b5 100644 --- a/SignNow.Net.Test/UnitTests/Services/EventSubscriptionServiceTest.cs +++ b/SignNow.Net.Test/UnitTests/Services/EventSubscriptionServiceTest.cs @@ -386,4 +386,102 @@ public async Task GetCallbacksAsync_WithNullRequestHeaders_ShouldHandleGracefull Assert.IsNotNull(callback.RequestContent); Assert.IsNotNull(callback.RequestContent.Content); } - }} + + [TestMethod] + public async Task GetCallbacksBySubscriptionIdAsync_WithValidSubscriptionId_ShouldReturnCallbacks() + { + var subscriptionId = "8a49e32e267e42a18e4a3967669f347e06e3b71e"; + var mockResponse = TestUtils.SerializeToJsonFormatted(new + { + data = new[] + { + new + { + id = "callback_123", + application_name = "TestApp", + entity_id = "doc_456", + event_subscription_id = subscriptionId, + event_subscription_active = true, + entity_type = "document", + event_name = "document.complete", + callback_url = "https://example.com/webhook", + request_method = "POST", + duration = 1.5, + request_start_time = 1609459200, + request_end_time = 1609459205, + request_headers = new + { + string_head = "test_value", + int_head = 42, + bool_head = true, + float_head = 3.14f + }, + response_content = "OK", + response_status_code = 200, + event_subscription_owner_email = "owner@example.com", + request_content = new + { + meta = new + { + timestamp = 1609459200, + @event = "document.complete", + environment = "https://api.signnow.com/", + initiator_id = "user_789", + callback_url = "https://example.com/webhook", + access_token = "***masked***" + }, + content = new + { + document_id = "doc_456", + document_name = "Test Document.pdf", + user_id = "user_789", + initiator_id = "user_789", + initiator_email = "initiator@example.com" + } + } + } + }, + meta = new + { + pagination = new + { + total = 1, + count = 1, + per_page = 50, + current_page = 1, + total_pages = 1 + } + } + }); + + var service = new EventSubscriptionService(ApiBaseUrl, new Token(), SignNowClientMock(mockResponse)); + var options = new GetCallbacksOptions + { + Page = 1, + PerPage = 50 + }; + + var response = await service.GetCallbacksBySubscriptionIdAsync(subscriptionId, options).ConfigureAwait(false); + + Assert.IsNotNull(response); + Assert.IsNotNull(response.Data); + Assert.IsNotNull(response.Meta); + Assert.AreEqual(1, response.Data.Count); + + var callback = response.Data[0]; + Assert.AreEqual("callback_123", callback.Id); + Assert.AreEqual("TestApp", callback.ApplicationName); + Assert.AreEqual("doc_456", callback.EntityId); + Assert.AreEqual(subscriptionId, callback.EventSubscriptionId); + Assert.IsTrue(callback.EventSubscriptionActive); + Assert.AreEqual(EventSubscriptionEntityType.Document, callback.EntityType); + Assert.AreEqual(EventType.DocumentComplete, callback.EventName); + Assert.AreEqual("https://example.com/webhook", callback.CallbackUrl.ToString()); + Assert.AreEqual(200, callback.ResponseStatusCode); + + Assert.AreEqual(1, response.Meta.Pagination.Total); + Assert.AreEqual(1, response.Meta.Pagination.Count); + Assert.AreEqual(50, response.Meta.Pagination.PerPage); + } + } +} diff --git a/SignNow.Net/Interfaces/IEventSubscriptionService.cs b/SignNow.Net/Interfaces/IEventSubscriptionService.cs index 37cd1fc1..4533f1c1 100644 --- a/SignNow.Net/Interfaces/IEventSubscriptionService.cs +++ b/SignNow.Net/Interfaces/IEventSubscriptionService.cs @@ -97,5 +97,16 @@ public interface IEventSubscriptionService /// Propagates notification that operations should be canceled. /// List of callback events with metadata Task GetCallbacksAsync(GetCallbacksOptions options = default, CancellationToken cancellationToken = default); + + /// + /// Allows users to get the list of webhook events (events history) by the subscription ID. + /// The results can be filtered and sorted. If the sort parameter is not indicated, + /// the results are sorted by the start_time in descending order. + /// + /// ID of the subscription + /// Options for filtering and sorting callbacks + /// Propagates notification that operations should be canceled. + /// List of callback events for the specified subscription with metadata + Task GetCallbacksBySubscriptionIdAsync(string subscriptionId, GetCallbacksOptions options = default, CancellationToken cancellationToken = default); } } diff --git a/SignNow.Net/Service/EventSubscriptionService.cs b/SignNow.Net/Service/EventSubscriptionService.cs index f793ddd6..7668ff2b 100644 --- a/SignNow.Net/Service/EventSubscriptionService.cs +++ b/SignNow.Net/Service/EventSubscriptionService.cs @@ -197,5 +197,26 @@ public async Task GetCallbacksAsync(GetCallbacksOptions optio .RequestAsync(requestOptions, cancellationToken) .ConfigureAwait(false); } + + /// + public async Task GetCallbacksBySubscriptionIdAsync(string subscriptionId, GetCallbacksOptions options = default, CancellationToken cancellationToken = default) + { + Token.TokenType = TokenType.Bearer; + + var query = options?.ToQueryString(); + var filters = string.IsNullOrEmpty(query) + ? string.Empty + : $"?{query}"; + + var requestOptions = new GetHttpRequestOptions + { + RequestUrl = new Uri(ApiBaseUrl, $"/v2/event-subscriptions/{subscriptionId.ValidateId()}/callbacks{filters}"), + Token = Token + }; + + return await SignNowClient + .RequestAsync(requestOptions, cancellationToken) + .ConfigureAwait(false); + } } } diff --git a/SignNow.props b/SignNow.props index dd5714a7..59fc0248 100644 --- a/SignNow.props +++ b/SignNow.props @@ -1,6 +1,6 @@ - 1.4.0 + 1.5.0 signNow signNow SignNow.NET From b7879d1c4496160b3175dcbf8749276784677d83 Mon Sep 17 00:00:00 2001 From: Andrii Date: Wed, 4 Feb 2026 14:18:16 +0200 Subject: [PATCH 2/4] Minor github action updates - update defaults - fetch-depth & shell --- .github/workflows/build_and_test.yml | 8 ++++---- .github/workflows/release.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index b07bf62b..35f8eb27 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -64,7 +64,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 1 + fetch-depth: 0 # For pull_request_target (dependabot), checkout the PR head SHA ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.ref }} @@ -121,7 +121,7 @@ jobs: } else { Write-Warning "SonarQube configuration file not found: $SonarConfigPath" } - + - name: SonarQube begin if: steps.sonar.outputs.has-project-key == 'true' run: | @@ -145,7 +145,7 @@ jobs: dotnet build SignNow.Net.Test --configuration Debug --no-incremental ` --framework ${{ matrix.framework }} ` ${{ env.DOTNET_BUILD_ARGS }} - + dotnet test SignNow.Net.Test --configuration Debug --no-build ` --framework ${{ matrix.framework }} ` --logger:trx --results-directory ./SignNow.Net.Test/TestResults ` @@ -156,7 +156,7 @@ jobs: with: name: CoverageReports-${{ runner.os }}-${{ matrix.framework }}.zip path: ${{ env.COVERAGE_PATH }}/**/coverage* - + - name: SonarQube end if: steps.sonar.outputs.has-project-key == 'true' continue-on-error: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45cd6f97..d50f59f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: defaults: run: - shell: pwsh + shell: bash steps: - uses: actions/checkout@v4 From 956110cc31607d4025ede861095030e4a4651596 Mon Sep 17 00:00:00 2001 From: Andrii Date: Wed, 4 Feb 2026 21:10:34 +0200 Subject: [PATCH 3/4] Refactoring, fixes: - ci/cd security issue - timeouts - detect new OS in runtime - add AGENTS.md --- .github/dependabot.yml | 2 +- .github/workflows/build_and_test.yml | 41 +++-- .github/workflows/release.yml | 16 +- AGENTS.md | 146 ++++++++++++++++++ .../EventSubscriptionServiceTest.List.cs | 8 +- .../UnitTests/Services/SignNowClientTest.cs | 4 +- SignNow.Net/Exceptions/SignNowException.cs | 4 +- SignNow.Net/Extensions/ValidatorExtensions.cs | 8 +- SignNow.Net/Model/EventSubscription.cs | 8 - .../Model/FieldContents/RadiobuttonContent.cs | 2 +- .../GetEventSubscriptionsListOptions.cs | 8 +- .../Model/Requests/GetFolderOptions.cs | 14 +- SignNow.Net/Service/DocumentGroupService.cs | 2 +- SignNow.Net/Service/SignNowClient.cs | 7 +- SignNow.Net/_Internal/Constants/ApiUrl.cs | 2 +- .../_Internal/Infrastructure/RuntimeInfo.cs | 44 ++++-- 16 files changed, 236 insertions(+), 80 deletions(-) create mode 100644 AGENTS.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 25c0ff88..034cdee3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,6 @@ updates: directory: "/" schedule: interval: "weekly" - target-branch: main + target-branch: develop commit-message: prefix: "ci: " diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 35f8eb27..f6d6806b 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -18,14 +18,6 @@ on: - '**.md' tags-ignore: - '**' - pull_request_target: - branches: - - 'main' - - 'develop' - paths-ignore: - - '**.md' - tags-ignore: - - '**' env: COREHOST_TRACE: false @@ -37,6 +29,11 @@ env: # Do not generate summary otherwise it leads to duplicate errors in build log DOTNET_BUILD_ARGS: /consoleloggerparameters:NoSummary /property:GenerateFullPaths=true + TEST_CREDITS_JSON: ${{ secrets.TEST_CREDITS_JSON }} + SONAR_SECRET: ${{ secrets.SONAR_SECRET }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + defaults: run: shell: pwsh @@ -62,11 +59,11 @@ jobs: - { name: 'Windows .NET 4.6', os: windows-latest, framework: 'net462', net-sdk: '8.0.x', target: 'net462' } steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 # For pull_request_target (dependabot), checkout the PR head SHA - ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.ref }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} - name: Get SDK Version id: props @@ -77,18 +74,18 @@ jobs: Write-Host "✓ Found SignNow.NET SDK project version: $Version" - name: Setup JDK 17 (SonarQube requirement) - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: '17' - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: ${{ matrix.net-sdk }} - name: Setup Nuget Cache - uses: actions/cache@v4 + uses: actions/cache@v5 id: nuget-cache with: path: ~/.nuget @@ -102,12 +99,14 @@ jobs: run: dotnet restore -v:n - name: Configure signNow API - run: echo '${{ secrets.TEST_CREDITS_JSON }}' >> ${{ github.workspace }}/api-eval.signnow.com.json + run: | + $outPath = Join-Path $Env:GITHUB_WORKSPACE 'api-eval.signnow.com.json' + [IO.File]::WriteAllText($outPath, $Env:TEST_CREDITS_JSON) - name: Get SonarQube Project Key id: sonar run: | - $SonarConfigPath = "${{ github.workspace }}/SonarQube.Analysis.xml" + $SonarConfigPath = Join-Path $Env:GITHUB_WORKSPACE 'SonarQube.Analysis.xml' if (Test-Path $SonarConfigPath) { [xml]$SonarConfig = Get-Content $SonarConfigPath $ProjectKey = $SonarConfig.SonarQubeAnalysisProperties.Property | Where-Object { $_.Name -eq "sonar.projectKey" } | Select-Object -ExpandProperty '#text' @@ -130,8 +129,8 @@ jobs: /s:${{ github.workspace }}/SonarQube.Analysis.xml ` /v:"${{ steps.props.outputs.version }}" ` /d:sonar.projectBaseDir="${{ github.workspace }}" ` - /d:sonar.token="${{ secrets.SONAR_SECRET }}" ` - /d:sonar.host.url="${{ secrets.SONAR_HOST_URL }}" ` + /d:sonar.token="${{ env.SONAR_SECRET }}" ` + /d:sonar.host.url="${{ env.SONAR_HOST_URL }}" ` /d:sonar.scanner.skipJreProvisioning=true - name: Run Tests on ${{ matrix.framework }} for TargetFramework ${{ matrix.target }} with Coverage @@ -152,7 +151,7 @@ jobs: /p:CollectCoverage=true - name: Save Code Coverage Results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: CoverageReports-${{ runner.os }}-${{ matrix.framework }}.zip path: ${{ env.COVERAGE_PATH }}/**/coverage* @@ -160,7 +159,7 @@ jobs: - name: SonarQube end if: steps.sonar.outputs.has-project-key == 'true' continue-on-error: true - run: dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_SECRET }}" + run: dotnet-sonarscanner end /d:sonar.token="${{ env.SONAR_SECRET }}" - name: Test Release Notes parser if: (runner.os == 'macOS' || runner.os == 'Linux') @@ -177,12 +176,10 @@ jobs: - name: Upload Code Coverage Report (Codecov.io) if: env.CODECOV_TOKEN != '' continue-on-error: true - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} uses: codecov/codecov-action@v5 with: name: ${{ runner.os }}-codecov-${{ matrix.framework }} flags: ${{ runner.os }},${{ matrix.target }} - token: ${{ secrets.CODECOV_TOKEN }} + token: ${{ env.CODECOV_TOKEN }} files: ${{ env.COVERAGE_PATH }}/${{ matrix.framework }}/coverage.${{ matrix.framework }}.opencover.xml fail_ci_if_error: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d50f59f9..988485c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,17 +21,17 @@ jobs: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup .NET 8 - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 7.0.x 8.0.x - name: Setup Nuget Cache - uses: actions/cache@v4 + uses: actions/cache@v5 id: nuget-cache with: path: ~/.nuget @@ -53,8 +53,10 @@ jobs: - name: Create Release uses: ncipollo/release-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ env.GITHUB_TOKEN }} name: signNow .Net SDK v${{ steps.get-version.outputs.VERSION }} tag: ${{ steps.get-version.outputs.VERSION }} bodyFile: ${{ github.workspace }}/release-notes.txt @@ -64,7 +66,9 @@ jobs: - name: Publish Nuget Package working-directory: ${{ github.workspace }}/SignNow.Net/bin/Publish + env: + NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} run: | - dotnet nuget push SignNow.Net.${{ steps.get-version.outputs.VERSION }}.nupkg \ - -k ${{ secrets.NUGET_TOKEN }} \ + dotnet nuget push SignNow.Net.${{ steps.get-version.outputs.VERSION }}.nupkg ` + -k ${{ env.NUGET_TOKEN }} ` -s https://api.nuget.org/v3/index.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..29c78f7a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,146 @@ +# SignNow .NET SDK - Architectural Context & Global Guidance + +This file establishes the architectural philosophy and coding standards for SignNow .NET SDK. +Applies to every agent session. + +## 1. System Identity +**Role:** Principal Software Architect and Technical Archaeologist of a Fortune 500 tech company +**Core Stack:** +- C# SDK built with multi-targeted MSBuild projects (`net462`, `netstandard2.0`, `netstandard2.1`, current modern .NET); + - HttpClient transport wrapped by `ISignNowClient`/`IHttpContentAdapter`; + - Newtonsoft.Json serialization; + - MSTest/xUnit-style suites under `SignNow.Net.Test`; shared configuration via `Directory.Build.props`, `SignNow.props`, and `netfx.props`. +**Philosophy:** +"The SDK as a Bridge" - This library is the trusted bridge between customer applications and SignNow's API. Every public surface must be intuitive, strongly-typed, and impossible to misuse. Customers should fall into the "pit of success" — correct usage should be the easiest path. +*Metaphor: "The Trusted Courier"* - every SDK call should feel like handing a critical contract to a concierge who takes the direct, recommended route with zero delays or detours. + +## 2. Strategic Vision +Deliver a robust, frictionless SDK so developers can add the NuGet package, configure `SignNowContext`, and issue their first API call within minutes. The SDK must behave identically across all supported target frameworks, surface only the latest SignNow API workflows, and teach best practices via self-contained, copy-paste-ready examples. +This SDK serves as the **official .NET integration point** for SignNow's electronic signature platform. Technical implications: + +- **Broad Compatibility:** Must compile and run identically across .NET Framework 4.6.2, .NET Standard 2.0/2.1, and modern .NET. No platform-specific APIs without conditional compilation. +- **Zero Friction Integration:** Customers must be able to add the NuGet package, configure `SignNowContext`, and make their first API call within minutes. +- **Copy-Paste Ready Examples:** The `/Examples` project is customer-facing documentation. Every example must be self-contained, runnable, and demonstrate best practices. +- **Single Path Principle:** When SignNow API offers multiple ways to accomplish a task, the SDK exposes only the latest/recommended approach. No legacy API endpoint wrappers. + +## 3. Architectural Boundaries +```plaintext ++---------------------------+ +| Consumer Apps / Examples | ++-------------+-------------+ + | + v ++---------------------------+ +| Public SDK Surface | +| (SignNowContext, DTOs) | ++-------------+-------------+ + | + v ++---------------------------+ +| Domain Services & Models | +| (Service/, _Internal/Model) ++-------------+-------------+ + | + v ++---------------------------+ +| Infrastructure & Transport| +| (_Internal/Infrastructure)| ++-------------+-------------+ + | + v ++---------------------------+ +| SignNow REST API | ++---------------------------+ +``` + +### Layer Rules (Access Matrix) +| Layer | CAN Access | CANNOT Access | +|-------|------------|---------------| +| Consumer Apps & `SignNow.Net.Examples` | Public SDK Surface | Domain internals, infrastructure helpers | +| Public SDK Surface (`SignNowContext`, `Interfaces/`, DTOs) | Domain Services & Models | Infrastructure plumbing, HTTP adapters | +| Domain Services & Models (`Service/`, `_Internal/Model`, mappers) | Infrastructure & Transport | Consumer utilities, UI concerns | +| Infrastructure & Transport (`_Internal/Infrastructure`, Helpers, adapters) | SignNow REST API, serialization libraries | Consumer apps, presentation layers | + +### Layer Constraints + +- **Public API Surface:** All public types must have XML documentation. No breaking changes without major version bump. +- **Service Layer:** Services are stateless. All state (tokens, configuration) flows through constructor injection or method parameters. +- **Infrastructure Layer:** HTTP concerns stay here. Services never see `HttpResponseMessage` or status codes directly. + +## 4. Data Flow & Patterns +```plaintext +Caller -> SignNowContext -> IService interface -> Domain Service +-> Request builder / validator -> IHttpContentAdapter -> HTTP pipeline +-> SignNow REST API -> Response translator -> DTO -> Caller +``` +- Orchestration stays thin at the surface; +- All rules live in services; +- Transport remains abstract and testable;\ +- Serialization/deserialization never leaks into consumer-facing layers. + +## 5. Development Constraints +* **Tech Stack Rules:** + - Preserve all existing target frameworks and MSBuild property imports; + - Guard any platform-specific API behind conditional compilation; + - Rely on the provided HttpClient abstractions and Newtonsoft.Json converters; + - Do not introduce new external SaaS dependencies. +* **State Management:** + - Keep `SignNowContext` and services stateless aside from injected tokens; + - Prefer async/await end-to-end with no sync-over-async; + - Cache credentials or documents only through approved adapters; + - Never store mutable state in statics except immutable configuration. +* **Critical Data Constraint:** + - Treat tokens, invites, document payloads, and signer identifiers as sensitive—never log raw values; + - Redact PII in diagnostics; + - Stream large documents to limit memory pressure; + - Normalize and persist timestamps in UTC; + - Respect API-provided idempotency keys when available; + - All API errors surface as `SignNowException` or its derivatives; + - Exception messages must be actionable for customers. +* **Documentation Rules:** + - All public types and members require XML documentation + - Use `` tags for common usage patterns + - Parameter descriptions must explain valid values and constraints + +## 6. Anti-Patterns (Forbidden) +- ❌ **YAGNI violations (You Aren't Gonna Need It):** Don't add functionality until it's actually needed. + - No "just in case" features, configurations, or abstractions + - No future-proofing for hypothetical requirements + - No generic solutions when a specific one solves the current problem + - Three similar lines of code is better than premature abstraction +- ❌ **Endpoint Forking:** Never expose multiple SDK paths for deprecated API endpoints; always guide callers through the single, recommended SignNow flow. +- ❌ **Platform Drift:** Introducing APIs unavailable on `net462`/`netstandard2.x` without conditional compilation or fallbacks is prohibited. +- ❌ **Async Blocking & Hidden Threads:** No `.Result`, `.Wait()`, or ad-hoc threads inside SDK services—stay purely async through the supplied HttpClient infrastructure. + +## 7. Critical File Locations +```plaintext +. +├─ AGENTS.md +├─ SignNow.Net.sln ← SACRED (DO NOT MODIFY) +├─ Directory.Build.props ← SACRED (DO NOT MODIFY) +├─ SignNow.props ← SACRED (DO NOT MODIFY) +├─ netfx.props ← SACRED (DO NOT MODIFY) +├─ .editorconfig ← SACRED (Coding conventions source of truth) +├─ SignNow.Net/ +│ ├─ SignNowContext.cs ← SACRED (Main DI entry point / Composition root) +│ ├─ Interfaces/ +│ ├─ Service/ +│ ├─ _Internal/ +│ │ ├─ Infrastructure/ +│ │ └─ Model/ +│ └─ Extensions/ +├─ SignNow.Net.Examples/ +│ ├─ ExamplesBase.cs ← SACRED (DO NOT MODIFY) +│ └─ Scenario folders (customer-facing) +├─ SignNow.Net.Test/ +│ ├─ AcceptanceTests/ +│ ├─ FeatureTests/ +│ └─ UnitTests/ +└─ logs/ (diagnostics only) +``` + +--- + +Last Updated: 2026-02-03 + +Maintained by: AI Agents under human supervision diff --git a/SignNow.Net.Test/AcceptanceTests/EventSubscriptionServiceTest.List.cs b/SignNow.Net.Test/AcceptanceTests/EventSubscriptionServiceTest.List.cs index 431a31d3..ea51de86 100644 --- a/SignNow.Net.Test/AcceptanceTests/EventSubscriptionServiceTest.List.cs +++ b/SignNow.Net.Test/AcceptanceTests/EventSubscriptionServiceTest.List.cs @@ -15,8 +15,10 @@ public partial class EventSubscriptionServiceTest : AuthorizedApiTestBase [TestMethod] public async Task GetEventSubscriptionsListAsync_WithFilters() { + const string TestDomain = "example.com"; + await SignNowTestContext.Events.CreateEventSubscriptionAsync( - new CreateEventSubscription(EventType.DocumentFreeformSigned, TestPdfDocumentId, new Uri("https://docs.signnow.com")) + new CreateEventSubscription(EventType.DocumentFreeformSigned, TestPdfDocumentId, new Uri($"https://{TestDomain}")) ).ConfigureAwait(false); var options = new GetEventSubscriptionsListOptions @@ -24,7 +26,7 @@ await SignNowTestContext.Events.CreateEventSubscriptionAsync( Page = 1, PerPage = 5, EventTypeFilter = EventTypeFilter.In(EventType.DocumentFreeformSigned), - CallbackUrlFilter = CallbackUrlFilter.Like("docs.signnow"), + CallbackUrlFilter = CallbackUrlFilter.Like(TestDomain), EntityIdFilter = EntityIdFilter.Like(TestPdfDocumentId), SortByCreated = SortOrder.Descending }; @@ -34,7 +36,7 @@ await SignNowTestContext.Events.CreateEventSubscriptionAsync( Assert.AreEqual( $"filters=[{{\"entity_id\":{{\"type\": \"like\", \"value\":\"{TestPdfDocumentId}\"}}}}, " + - $"{{\"callback_url\":{{\"type\": \"like\", \"value\":\"docs.signnow\"}}}}, " + + $"{{\"callback_url\":{{\"type\": \"like\", \"value\":\"{TestDomain}\"}}}}, " + $"{{\"event\":{{\"type\": \"in\", \"value\":[\"document.freeform.signed\"]}}}}]" + $"&sort[created]=desc&page=1&per_page=5", options.ToQueryString() diff --git a/SignNow.Net.Test/UnitTests/Services/SignNowClientTest.cs b/SignNow.Net.Test/UnitTests/Services/SignNowClientTest.cs index 4d6a1a64..7f8ff8c7 100644 --- a/SignNow.Net.Test/UnitTests/Services/SignNowClientTest.cs +++ b/SignNow.Net.Test/UnitTests/Services/SignNowClientTest.cs @@ -104,7 +104,7 @@ public async Task ShouldHandleTimeoutExceptionFromHttpClient() .Protected() .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .ThrowsAsync( - new TaskCanceledException("A task was canceled."), TimeSpan.FromSeconds(3)) + new TaskCanceledException("A task was canceled."), TimeSpan.FromSeconds(2)) .Verifiable(); var httpClient = new HttpClient(handlerMock.Object, false); @@ -126,7 +126,7 @@ public async Task ShouldHandleTimeoutExceptionFromHttpClient() ExceptionMessages.UnableToProcessRequest, "GET", ApiBaseUrl + "user", ""); - StringAssert.Matches(exception.Message, new Regex(errorMessage.TrimEnd('s') + "\\d\\.\\d+s")); + StringAssert.Matches(exception.Message, new Regex(errorMessage.TrimEnd('s') + "\\d+s")); #if !NET45_OR_GREATER StringAssert.Contains(exception.InnerException?.Message, "The request was canceled due to the configured HttpClient.Timeout of 1 seconds elapsing."); diff --git a/SignNow.Net/Exceptions/SignNowException.cs b/SignNow.Net/Exceptions/SignNowException.cs index dcae0847..96336b64 100644 --- a/SignNow.Net/Exceptions/SignNowException.cs +++ b/SignNow.Net/Exceptions/SignNowException.cs @@ -27,7 +27,9 @@ public HttpStatusCode HttpStatusCode public IEnumerable>> RawHeaders { - get => Data.Contains("RawHeaders") ? (IEnumerable>>)Data["RawHeaders"] : default; + get => Data.Contains("RawHeaders") + ? (IEnumerable>>)Data["RawHeaders"] + : Array.Empty>>(); set => Data["RawHeaders"] = value; } diff --git a/SignNow.Net/Extensions/ValidatorExtensions.cs b/SignNow.Net/Extensions/ValidatorExtensions.cs index acafc232..36d5fad1 100644 --- a/SignNow.Net/Extensions/ValidatorExtensions.cs +++ b/SignNow.Net/Extensions/ValidatorExtensions.cs @@ -32,7 +32,7 @@ public static bool IsValidId(this string id) if (string.IsNullOrWhiteSpace(id)) return false; - var regex = new Regex(IdPattern); + var regex = new Regex(IdPattern, RegexOptions.None, TimeSpan.FromMilliseconds(100)); return regex.IsMatch(id); } @@ -43,7 +43,7 @@ public static bool IsValidId(this string id) /// Invalid format of ID. public static string ValidateId(this string id) { - var regex = new Regex(IdPattern); + var regex = new Regex(IdPattern, RegexOptions.None, TimeSpan.FromMilliseconds(100)); if (regex.IsMatch(id) && !string.IsNullOrWhiteSpace(id)) return id; @@ -61,7 +61,7 @@ public static bool IsValidEmail(this string email) if (string.IsNullOrWhiteSpace(email)) return false; - var regex = new Regex(EmailPattern, RegexOptions.IgnoreCase); + var regex = new Regex(EmailPattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100)); return regex.IsMatch(email); } @@ -73,7 +73,7 @@ public static bool IsValidEmail(this string email) /// if email address is not valid. public static string ValidateEmail(this string email) { - var regex = new Regex(EmailPattern, RegexOptions.IgnoreCase); + var regex = new Regex(EmailPattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100)); if (regex.IsMatch(email) && !string.IsNullOrWhiteSpace(email)) return email; diff --git a/SignNow.Net/Model/EventSubscription.cs b/SignNow.Net/Model/EventSubscription.cs index e8e84089..eb6c8507 100644 --- a/SignNow.Net/Model/EventSubscription.cs +++ b/SignNow.Net/Model/EventSubscription.cs @@ -98,14 +98,6 @@ public class EventSubscription public class EventAttributes { - // /// - // /// Determines whether to keep access_token in the payload. - // /// If true, then we should delete access_token key from payload. - // /// If false, keep the access_token in payload attributes - // /// - // [JsonProperty("delete_access_token")] - // public bool DeleteAccessToken { get; set; } = true; - /// /// If true, 1.2 tls version will be used. If false, default tls version will be used. /// diff --git a/SignNow.Net/Model/FieldContents/RadiobuttonContent.cs b/SignNow.Net/Model/FieldContents/RadiobuttonContent.cs index 45201391..1579e9c9 100644 --- a/SignNow.Net/Model/FieldContents/RadiobuttonContent.cs +++ b/SignNow.Net/Model/FieldContents/RadiobuttonContent.cs @@ -49,7 +49,7 @@ public class RadiobuttonContent : BaseContent /// Returns Radiobutton content (actual state) as string. /// [JsonIgnore] - public string Data => Radio.FirstOrDefault(itm => itm.Checked == true)?.Data; + public string Data => Radio.FirstOrDefault(itm => itm.Checked)?.Data; /// /// Returns Radiobutton content (actual state) as string. diff --git a/SignNow.Net/Model/Requests/GetEventSubscriptionsListOptions.cs b/SignNow.Net/Model/Requests/GetEventSubscriptionsListOptions.cs index 26a7388b..8d2020fc 100644 --- a/SignNow.Net/Model/Requests/GetEventSubscriptionsListOptions.cs +++ b/SignNow.Net/Model/Requests/GetEventSubscriptionsListOptions.cs @@ -77,11 +77,11 @@ public class GetEventSubscriptionsListOptions : IQueryToString public string ToQueryString() { var parameters = new List(); - + var filters = new List { ApplicationFilter, DateFilter, EntityIdFilter, CallbackUrlFilter, EventTypeFilter } .Where(f => f != null) - .Select(f => f?.FilterExpression); - if(filters.Count() > 0) + .Select(f => f.FilterExpression); + if(filters.Any()) { parameters.Add($"filters=[{string.Join(", ", filters)}]"); } @@ -141,7 +141,7 @@ public class EventSubscriptionFilter /// Initializes a new instance of the class. /// This class helps create filters in format Filter.In("a", "b"), Filter.Equal("a") etc. /// - /// The filter expression string. Builded with help of CreateSingleValueFilter, CreateArrayValueFilter. + /// The filter expression string. Built with help of CreateSingleValueFilter, CreateArrayValueFilter. protected EventSubscriptionFilter(string filterExpression) { FilterExpression = filterExpression; diff --git a/SignNow.Net/Model/Requests/GetFolderOptions.cs b/SignNow.Net/Model/Requests/GetFolderOptions.cs index 6dda500e..c391d836 100644 --- a/SignNow.Net/Model/Requests/GetFolderOptions.cs +++ b/SignNow.Net/Model/Requests/GetFolderOptions.cs @@ -49,7 +49,7 @@ public class GetFolderOptions : IQueryToString [JsonIgnore] public int Limit { - get => InternalLimit.Values.FirstOrDefault(); + get => InternalLimit?.Values.FirstOrDefault() ?? 0; set { if (value >= 0) @@ -63,7 +63,7 @@ public int Limit [JsonIgnore] public int Offset { - get => InternalOffset.Values.FirstOrDefault(); + get => InternalOffset?.Values.FirstOrDefault() ?? 0; set => InternalOffset = new Dictionary {{"offset", value > 0 ? value : 0}}; } @@ -78,7 +78,7 @@ public int Offset [JsonIgnore] public EntityType EntityTypes { - get => InternalEntityType.Values.FirstOrDefault(); + get => InternalEntityType?.Values.FirstOrDefault() ?? default; set => InternalEntityType = new Dictionary {{"entity_type", value}}; } @@ -91,7 +91,7 @@ public EntityType EntityTypes [JsonIgnore] public SubFolders SubfolderData { - get => InternalSubfolderData.Values.FirstOrDefault(); + get => InternalSubfolderData?.Values.FirstOrDefault() ?? default; set => InternalSubfolderData = new Dictionary {{"subfolder-data", value}}; } @@ -102,7 +102,7 @@ public SubFolders SubfolderData [JsonIgnore] public bool WithTeamDocuments { - get => InternalWithTeamDocument.Values.FirstOrDefault(); + get => InternalWithTeamDocument?.Values.FirstOrDefault() ?? false; set => InternalWithTeamDocument = new Dictionary {{"with_team_documents", value}}; } @@ -115,7 +115,7 @@ public bool WithTeamDocuments [JsonIgnore] public bool IncludeDocumentsSubfolder { - get => InternalIncludeDocumentsSubfolder.Values.FirstOrDefault(); + get => InternalIncludeDocumentsSubfolder?.Values.FirstOrDefault() ?? true; set => InternalIncludeDocumentsSubfolder = new Dictionary {{"include_documents_subfolders", value}}; } @@ -124,7 +124,7 @@ public bool IncludeDocumentsSubfolder /// public bool ExcludeDocumentsRelations { - get => InternalExcludeDocumentsRelations.Values.FirstOrDefault(); + get => InternalExcludeDocumentsRelations?.Values.FirstOrDefault() ?? false; set => InternalExcludeDocumentsRelations = new Dictionary {{"exclude_documents_relations", value}}; } diff --git a/SignNow.Net/Service/DocumentGroupService.cs b/SignNow.Net/Service/DocumentGroupService.cs index bdbbbe2c..0358593b 100644 --- a/SignNow.Net/Service/DocumentGroupService.cs +++ b/SignNow.Net/Service/DocumentGroupService.cs @@ -81,7 +81,7 @@ public async Task GetDocumentGroupsAsync(IQueryToString throw new ArgumentException("Offset must be 0 or greater.", nameof(options)); } - var query = options?.ToQueryString(); + var query = options.ToQueryString(); var filters = string.IsNullOrEmpty(query) ? string.Empty : $"?{query}"; diff --git a/SignNow.Net/Service/SignNowClient.cs b/SignNow.Net/Service/SignNowClient.cs index ea88c503..7e7e7b41 100644 --- a/SignNow.Net/Service/SignNowClient.cs +++ b/SignNow.Net/Service/SignNowClient.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; @@ -102,7 +103,7 @@ public async Task RequestAsync(RequestOptions requestOptio { Guard.ArgumentNotNull(adapter, nameof(adapter)); - DateTime startTime = DateTime.Now; + var stopWatch = Stopwatch.StartNew(); try { @@ -115,12 +116,12 @@ public async Task RequestAsync(RequestOptions requestOptio } catch (TaskCanceledException ex) { - var requestTime = (DateTime.Now - startTime).TotalSeconds; + stopWatch.Stop(); var message = string.Format(CultureInfo.CurrentCulture, ExceptionMessages.UnableToProcessRequest, requestOptions.HttpMethod.Method, requestOptions.RequestUrl.OriginalString, - requestTime); + stopWatch.ElapsedMilliseconds); throw new SignNowException(message, ex); } diff --git a/SignNow.Net/_Internal/Constants/ApiUrl.cs b/SignNow.Net/_Internal/Constants/ApiUrl.cs index e3430eed..b2e94696 100644 --- a/SignNow.Net/_Internal/Constants/ApiUrl.cs +++ b/SignNow.Net/_Internal/Constants/ApiUrl.cs @@ -16,7 +16,7 @@ internal static class ApiUrl /// /// Base signNow API URL for Release configuration. /// - public static Uri ApiBaseUrl = new Uri("https://api.signnow.com"); + public static readonly Uri ApiBaseUrl = new Uri("https://api.signnow.com"); #endif } } diff --git a/SignNow.Net/_Internal/Infrastructure/RuntimeInfo.cs b/SignNow.Net/_Internal/Infrastructure/RuntimeInfo.cs index 8aa0342e..1ceeb125 100644 --- a/SignNow.Net/_Internal/Infrastructure/RuntimeInfo.cs +++ b/SignNow.Net/_Internal/Infrastructure/RuntimeInfo.cs @@ -45,7 +45,7 @@ static RuntimeInfo() /// Windows, Linux, macOS or Unknown public static string GetOSName() { - var os = "Unknown"; + var os = String.Empty; #if NETFRAMEWORK os = Environment.OSVersion.ToString(); #else @@ -93,11 +93,10 @@ private static string GetOsVersion() /// private static string GetArchitecture() { - var arch = "x86_64"; #if NETFRAMEWORK - arch = typeof(RuntimeInfo).Assembly.GetName().ProcessorArchitecture.ToString(); + var arch = typeof(RuntimeInfo).Assembly.GetName().ProcessorArchitecture.ToString(); #else - arch = RuntimeInformation.OSArchitecture.ToString(); + var arch = RuntimeInformation.OSArchitecture.ToString(); #endif return arch; } @@ -108,11 +107,10 @@ private static string GetArchitecture() /// private static string GetPlatform() { - var platform = "unknown"; #if NETFRAMEWORK - platform = Environment.Is64BitOperatingSystem ? "win64" : "win32"; + var platform = Environment.Is64BitOperatingSystem ? "win64" : "win32"; #else - platform = RuntimeInformation.ProcessArchitecture.ToString(); + var platform = RuntimeInformation.ProcessArchitecture.ToString(); #endif return platform; } @@ -125,10 +123,14 @@ private static string GetPlatform() /// public static string GetWindowsVersion(string osDescription) { - string version = String.Empty; + var version = String.Empty; #if NETSTANDARD // Microsoft Windows 10.0.18363 - var matched = new Regex(@"(?\w+)+\s+(?\d+.?\d+(?:\S)\S+)").Match(osDescription.Trim()); + var matched = Regex.Match( + osDescription.Trim(), + @"(?\w+)+\s+(?\d+.?\d+(?:\S)\S+)", + RegexOptions.None, + TimeSpan.FromMilliseconds(100)); version = matched.Groups["version"].Value.Trim(); #endif return version; @@ -141,30 +143,35 @@ public static string GetWindowsVersion(string osDescription) /// public static string GetLinuxVersion(string kernel) { - string version = String.Empty; + var version = String.Empty; #if NETSTANDARD // Linux 4.4.0 - 43 - Microsoft #1-Microsoft Wed Dec 31 14:42:53 PST 2014 // Linux 3.10.0-693.21.1.el7.x86_64 #1 SMP Wed Mar 7 19:03:37 UTC 2018 - var matched = new Regex(@"^(?\w+)+\s+(?\d+.?\d+(?:\S)\S+)").Match(kernel.Trim()); + var matched = Regex.Match( + kernel.Trim(), + @"^(?\w+)+\s+(?\d+.?\d+(?:\S)\S+)", + RegexOptions.None, + TimeSpan.FromMilliseconds(100) + ); version = matched.Groups["version"].Value.Trim(); #endif return version; } /// - /// Get MacOs version from string with kernel details. + /// Get macOS version from string with kernel details. /// /// /// public static string GetMacOsVersion(string kernel) { - string version = String.Empty; + var version = String.Empty; #if NETSTANDARD /* Darwin 17.5.0 Darwin Kernel Version 17.5.0: Mon Mar 5 22:24:32 PST 2018; root:xnu-4570.51.1~1/RELEASE_X86_64 */ var matched = new Regex(@"^(?\w+)+\s+(?\d+).(?\d+).(?\d+)?").Match(kernel.Trim()); - int major = int.Parse(matched.Groups["major"].Value, CultureInfo.InvariantCulture); - int minor = int.Parse(matched.Groups["minor"].Value, CultureInfo.InvariantCulture); + var major = int.Parse(matched.Groups["major"].Value, CultureInfo.InvariantCulture); + var minor = int.Parse(matched.Groups["minor"].Value, CultureInfo.InvariantCulture); switch (major) { @@ -248,6 +255,10 @@ public static string GetMacOsVersion(string kernel) version = "15.0"; break; + case (int)MacOsVersions.macOS2600: + version = "26.0"; + break; + default: break; } @@ -280,7 +291,8 @@ internal enum MacOsVersions macOS1200, macOS1300, macOS1400, - macOS1500 + macOS1500, + macOS2600 } } } From 9e0191a9f5099a15a9d94cee5ebea10d3f30ce91 Mon Sep 17 00:00:00 2001 From: Andrii Date: Thu, 5 Feb 2026 00:37:11 +0200 Subject: [PATCH 4/4] Fix release github action --- .github/workflows/release.yml | 5 +++-- CHANGELOG.md | 2 -- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 988485c2..e0602186 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,9 +66,10 @@ jobs: - name: Publish Nuget Package working-directory: ${{ github.workspace }}/SignNow.Net/bin/Publish + shell: pwsh env: NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} run: | dotnet nuget push SignNow.Net.${{ steps.get-version.outputs.VERSION }}.nupkg ` - -k ${{ env.NUGET_TOKEN }} ` - -s https://api.nuget.org/v3/index.json + --api-key ${{ env.NUGET_TOKEN }} ` + --source https://api.nuget.org/v3/index.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 48463833..2173f3e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com) and this project adheres to [Semantic Versioning](http://semver.org). ## [Unreleased] - TBD - -## [1.5.0] - 2026-02-05 ### Added - Event Subscriptions & Webhooks: - Get Callbacks History by ID: webhook callback event history related to subscription ID with advanced filtering and sorting capabilities