From 3190d2ac16efd7d39d2ecea89d12138a32461cb7 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 12:51:40 -0500 Subject: [PATCH 01/17] Migrated project to .NET 10, updated dependencies, replaced pipelines with GitHub Actions, and adopted Nerdbank.GitVersioning. --- .github/workflows/ci.yml | 138 ++++++++++++++++++ .gitignore | 6 +- azure-pipelines.yml | 98 ------------- docs/release-process.md | 92 ++++++++++++ gitversion.yml | 25 ---- global.json | 13 ++ src/.editorconfig | 7 + src/Directory.Build.props | 26 ++++ src/Directory.Packages.props | 21 +++ src/TenantCloud.slnx | 4 + .../TestBase.cs | 9 +- .../Yllibed.TenantCloudClient.Tests.csproj | 8 +- src/Yllibed.TenantCloudClient.sln | 38 ----- .../Properties/AssemblyAttributes.cs | 3 - .../Yllibed.TenantCloudClient.csproj | 17 ++- version.json | 19 +++ 16 files changed, 341 insertions(+), 183 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 azure-pipelines.yml create mode 100644 docs/release-process.md delete mode 100644 gitversion.yml create mode 100644 global.json create mode 100644 src/.editorconfig create mode 100644 src/Directory.Build.props create mode 100644 src/Directory.Packages.props create mode 100644 src/TenantCloud.slnx delete mode 100644 src/Yllibed.TenantCloudClient.sln delete mode 100644 src/Yllibed.TenantCloudClient/Properties/AssemblyAttributes.cs create mode 100644 version.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cdd0399 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,138 @@ +name: CI + +on: + pull_request: + push: + branches: [master, 'release/**'] + +permissions: + contents: write + checks: write + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + +jobs: + build-test-pack: + name: Build, Test, Pack + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 # Nerdbank.GitVersioning needs full history + + - name: Prepare release branch + if: startsWith(github.ref, 'refs/heads/release/') + shell: pwsh + run: | + $content = Get-Content version.json -Raw + if ($content -match '"version"\s*:\s*"[^"]*-') { + $content = $content -replace '("version"\s*:\s*"[^"]*)-[^"]*"', '$1"' + Set-Content version.json $content -NoNewline + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add version.json + git commit -m "chore: strip pre-release tag for release branch" + git push + } + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: '10.0.x' + dotnet-quality: ga + + - name: Restore + run: dotnet restore src/TenantCloud.slnx --force + + - name: Build + run: dotnet build src/TenantCloud.slnx -c Release -warnaserror --no-restore + + - name: Test + run: >- + dotnet test --solution src/TenantCloud.slnx + -c Release + --no-build + --results-directory TestResults + -- + --report-trx + --coverage + --coverage-output-format cobertura + + - name: Test report + if: always() + uses: dorny/test-reporter@v1 + with: + name: Test Results + path: 'TestResults/**/*.trx' + reporter: dotnet-trx + + - name: Coverage summary + if: always() + shell: pwsh + run: | + $coberturaFiles = Get-ChildItem -Path TestResults -Filter '*.cobertura.xml' -Recurse -ErrorAction SilentlyContinue + if (-not $coberturaFiles -or $coberturaFiles.Count -eq 0) { + Write-Host 'No Cobertura coverage files found — skipping summary.' + exit 0 + } + foreach ($file in $coberturaFiles) { + [xml]$xml = Get-Content $file.FullName + $lineRate = [math]::Round([double]$xml.coverage.'line-rate' * 100, 1) + $branchRate = [math]::Round([double]$xml.coverage.'branch-rate' * 100, 1) + "## Code Coverage`n" | Out-File -Append $env:GITHUB_STEP_SUMMARY + "| Metric | Value |" | Out-File -Append $env:GITHUB_STEP_SUMMARY + "|--------|-------|" | Out-File -Append $env:GITHUB_STEP_SUMMARY + "| Line coverage | ${lineRate}% |" | Out-File -Append $env:GITHUB_STEP_SUMMARY + "| Branch coverage | ${branchRate}% |" | Out-File -Append $env:GITHUB_STEP_SUMMARY + Write-Host "Line coverage: ${lineRate}%, Branch coverage: ${branchRate}%" + } + + - name: Pack + run: dotnet pack src/TenantCloud.slnx -c Release --no-restore -o ${{ runner.temp }}/packages + + - name: Upload test results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: test-results + path: TestResults/** + retention-days: 14 + + - name: Upload packages + if: success() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: packages + path: ${{ runner.temp }}/packages/*.nupkg + retention-days: 14 + + - name: Resolve version + id: version + if: success() && github.event_name == 'push' + shell: pwsh + run: | + dotnet tool install -g nbgv + $v = nbgv get-version -v NuGetPackageVersion + echo "version=$v" >> $env:GITHUB_OUTPUT + + - name: Publish to NuGet + if: success() && github.event_name == 'push' + run: >- + dotnet nuget push "${{ runner.temp }}/packages/*.nupkg" + --source https://api.nuget.org/v3/index.json + --api-key ${{ secrets.NUGET_API_KEY }} + --skip-duplicate + + - name: Create GitHub Release + if: success() && github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release/') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: >- + gh release create "v${{ steps.version.outputs.version }}" + "${{ runner.temp }}/packages/*.nupkg" + --title "v${{ steps.version.outputs.version }}" + --generate-notes diff --git a/.gitignore b/.gitignore index 4ce6fdd..dda10e6 100644 --- a/.gitignore +++ b/.gitignore @@ -337,4 +337,8 @@ ASALocalRun/ .localhistory/ # BeatPulse healthcheck temp database -healthchecksdb \ No newline at end of file +healthchecksdb + +# TenantCloud MCP auth tokens (secrets!) +.tenantcloud-mcp/ +.claude/ diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index c5a9d7e..0000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,98 +0,0 @@ -# .NET Desktop -# Build and run tests for .NET Desktop or Windows classic desktop solutions. -# Add steps that publish symbols, save build artifacts, and more: -# https://docs.microsoft.com/azure/devops/pipelines/apps/windows/dot-net - -trigger: -- master - -pr: -- master - -variables: - solution: '**/*.sln' - buildPlatform: 'Any CPU' - buildConfiguration: 'Release' - nugetPackages: '**\*.nupkg' - -stages: - - stage: Build - jobs: - - job: Build - pool: - vmImage: 'windows-latest' - - steps: - - task: GitVersion@4 - - - - task: NuGetCommand@2 - inputs: - restoreSolution: '$(solution)' - - - task: VSBuild@1 - inputs: - solution: '$(solution)' - msbuildArgs: '/p:Version=$(GitVersion.AssemblySemVer) /p:PackageVersion=$(GitVersion.FullSemVer) /p:AssemblyVersion=$(GitVersion.AssemblySemVer) /p:FileVersion=$(GitVersion.AssemblySemFileVer) /p:InformationalVersion=$(GitVersion.InformationalVersion) /p:SkipInvalidConfigurations=true' - platform: '$(buildPlatform)' - configuration: '$(buildConfiguration)' - restoreNugetPackages: true - - - task: CopyFiles@2 - inputs: - SourceFolder: $(build.sourcesdirectory) - Contents: $(nugetPackages) - TargetFolder: $(build.artifactstagingdirectory) - CleanTargetFolder: false - OverWrite: false - flattenFolders: true - - - task: VSTest@2 - inputs: - testSelector: 'testAssemblies' - testAssemblyVer2: | - **\*tests.dll - !**\*TestAdapter.dll - !**\obj\** - searchFolder: '$(System.DefaultWorkingDirectory)' - runTestsInIsolation: true - codeCoverageEnabled: true - platform: '$(buildPlatform)' - configuration: '$(buildConfiguration)' - failOnMinTestsNotRun: true - minimumExpectedTests: '5' - - - task: PublishBuildArtifacts@1 - condition: always() - inputs: - PathtoPublish: $(build.artifactstagingdirectory) - ArtifactName: NugetPackages - ArtifactType: Container - - - stage: Publish - condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest')) - jobs: - - deployment: PublishNuget - environment: nuget_org - strategy: - runOnce: - deploy: - steps: - - task: NuGetCommand@2 - inputs: - command: 'push' - packagesToPush: '$(Pipeline.Workspace)/**/*.nupkg;!$(Pipeline.Workspace)/**/*.symbols.nupkg' - nuGetFeedType: 'external' - publishFeedCredentials: 'nuget.org-carldebilly' - - - task: GitHubRelease@1 - inputs: - gitHubConnection: 'carldebilly' - repositoryName: '$(Build.Repository.Name)' - action: 'create' - target: '$(Build.SourceVersion)' - tagSource: 'userSpecifiedTag' - tag: '$(Build.BuildNumber)' - title: '$(Build.BuildNumber)' - changeLogCompareToRelease: 'lastNonDraftRelease' - changeLogType: 'commitBased' diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..c156c49 --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,92 @@ +# Release Process + +## Versioning + +This project uses [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) +for automatic versioning based on git history. The version is configured in +[`version.json`](../version.json) at the repo root. + +### Version format + +| Branch | NuGet version | Example | Pre-release? | +|--------|--------------|---------|:------------:| +| `master` | `{major}.{minor}.{height}-dev` | `3.0.42-dev` | Yes | +| `release/**` | `{major}.{minor}.{height}` | `3.0.42` | No | +| `dev/**` / PRs | `{major}.{minor}.{height}-dev.g{hash}` | `3.0.42-dev.ga1b2c3d` | Yes | + +- **height** = number of commits since `version.json` was last modified. +- On `master` and `release/**` (public branches), the git hash is omitted. +- On feature branches and PRs, the git hash is appended to guarantee uniqueness. + +## CI Pipeline + +The CI pipeline (`.github/workflows/ci.yml`) runs on **every push** to `master` +and `release/**`, and on **every pull request**. + +### What runs when + +| Trigger | Build + Test | NuGet Publish | GitHub Release | +|---------|:------------:|:-------------:|:--------------:| +| Pull request | Yes | No | No | +| Push to `master` | Yes | Yes (pre-release) | No | +| Push to `release/**` | Yes | Yes (stable) | Yes | + +### Required secrets + +| Secret | Description | +|--------|-------------| +| `NUGET_API_KEY` | API key for nuget.org (configure in repo Settings > Secrets > Actions) | +| `GITHUB_TOKEN` | Provided automatically by GitHub Actions (used for creating releases) | + +## Publishing a stable release + +1. **Create the release branch** from `master`: + + ```bash + git checkout master + git pull + git checkout -b release/v3.0 + git push -u origin release/v3.0 + ``` + +2. **The CI takes care of the rest automatically:** + - Detects `-dev` in `version.json` and strips it (commits the change) + - Builds, tests, and packs the library + - Publishes the stable `.nupkg` to nuget.org + - Creates a GitHub Release with auto-generated release notes + +3. **If a hotfix is needed** on the release branch, commit directly to it. + The CI will build and publish an incremented patch version. + +## Bumping the major/minor version + +After creating a release branch, bump the version on `master` for the next +development cycle: + +```bash +git checkout master +# Edit version.json: change "3.0-dev" to "3.1-dev" (or "4.0-dev") +git add version.json +git commit -m "Bump version to 3.1-dev" +git push +``` + +Alternatively, use the `nbgv` CLI: + +```bash +dotnet tool install -g nbgv +nbgv prepare-release +``` + +This command automatically: +- Creates a `release/v{version}` branch with the stable version +- Bumps `master` to the next minor version with `-dev` suffix + +## Local version check + +To see what version would be produced from your current commit: + +```bash +dotnet tool install -g nbgv +nbgv get-version +``` diff --git a/gitversion.yml b/gitversion.yml deleted file mode 100644 index f9efb81..0000000 --- a/gitversion.yml +++ /dev/null @@ -1,25 +0,0 @@ - -assembly-versioning-scheme: MajorMinorPatch -mode: Mainline -next-version: 2.1.0 -continuous-delivery-fallback-tag: "" -branches: - master: - increment: minor - mode: ContinuousDeployment - tag: dev - regex: master - - stable: - regex: stable - tag: "" - increment: patch - source-branches: ['master'] - - dev: - regex: dev/.*?/(.*?) - tag: dev.{BranchName} - source-branches: ['master'] - -ignore: - sha: [] diff --git a/global.json b/global.json new file mode 100644 index 0000000..a552c1f --- /dev/null +++ b/global.json @@ -0,0 +1,13 @@ +{ + "sdk": { + "version": "10.0.102", + "rollForward": "latestFeature", + "allowPrerelease": false + }, + "msbuild-sdks": { + "MSTest.Sdk": "4.1.0" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 0000000..852d658 --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,7 @@ +[*.cs] +# VSTHRD200: Async suffix - suppressed because renaming public API methods is a breaking change +dotnet_diagnostic.VSTHRD200.severity = none + +# MA0009: Regex DoS - source-generated regexes with timeout are addressed where practical; +# complex email regex is intentionally accepted as-is +dotnet_diagnostic.MA0009.severity = suggestion diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..c02fd87 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,26 @@ + + + + latest + enable + enable + + true + true + true + true + true + + + + + true + + + + + + + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props new file mode 100644 index 0000000..aa81c5a --- /dev/null +++ b/src/Directory.Packages.props @@ -0,0 +1,21 @@ + + + + true + + + + + + + + + + + + + + + + + diff --git a/src/TenantCloud.slnx b/src/TenantCloud.slnx new file mode 100644 index 0000000..e525542 --- /dev/null +++ b/src/TenantCloud.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/src/Yllibed.TenantCloudClient.Tests/TestBase.cs b/src/Yllibed.TenantCloudClient.Tests/TestBase.cs index 4f36dc2..eb9669a 100644 --- a/src/Yllibed.TenantCloudClient.Tests/TestBase.cs +++ b/src/Yllibed.TenantCloudClient.Tests/TestBase.cs @@ -1,11 +1,8 @@ -using Microsoft.Extensions.Configuration; +namespace Yllibed.TenantCloudClient.Tests; -namespace Yllibed.TenantCloudClient.Tests +public class TestBase { - public class TestBase + internal TestBase() { - internal TestBase() - { - } } } diff --git a/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj b/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj index 430dc06..e1f9746 100644 --- a/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj +++ b/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj @@ -1,9 +1,9 @@ - + - netcoreapp3.1 - enable - false + net10.0 + false + false diff --git a/src/Yllibed.TenantCloudClient.sln b/src/Yllibed.TenantCloudClient.sln deleted file mode 100644 index 6a539f9..0000000 --- a/src/Yllibed.TenantCloudClient.sln +++ /dev/null @@ -1,38 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.28714.193 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Yllibed.TenantCloudClient", "Yllibed.TenantCloudClient\Yllibed.TenantCloudClient.csproj", "{F698BDE6-7498-4CE6-8BE8-F1AA3339E04A}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Project", "Project", "{89B0D1D7-7A2C-41CB-A570-1A5D2022E7DA}" - ProjectSection(SolutionItems) = preProject - ..\azure-pipelines.yml = ..\azure-pipelines.yml - ..\gitversion.yml = ..\gitversion.yml - ..\README.md = ..\README.md - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yllibed.TenantCloudClient.Tests", "Yllibed.TenantCloudClient.Tests\Yllibed.TenantCloudClient.Tests.csproj", "{BCED9B73-EB62-4BCF-8476-D00BDD70A2C6}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {F698BDE6-7498-4CE6-8BE8-F1AA3339E04A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F698BDE6-7498-4CE6-8BE8-F1AA3339E04A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F698BDE6-7498-4CE6-8BE8-F1AA3339E04A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F698BDE6-7498-4CE6-8BE8-F1AA3339E04A}.Release|Any CPU.Build.0 = Release|Any CPU - {BCED9B73-EB62-4BCF-8476-D00BDD70A2C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BCED9B73-EB62-4BCF-8476-D00BDD70A2C6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BCED9B73-EB62-4BCF-8476-D00BDD70A2C6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BCED9B73-EB62-4BCF-8476-D00BDD70A2C6}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {A9DC6DDF-6EB9-4227-BCDD-6D8DBE08D663} - EndGlobalSection -EndGlobal diff --git a/src/Yllibed.TenantCloudClient/Properties/AssemblyAttributes.cs b/src/Yllibed.TenantCloudClient/Properties/AssemblyAttributes.cs deleted file mode 100644 index 1abe3a7..0000000 --- a/src/Yllibed.TenantCloudClient/Properties/AssemblyAttributes.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("Yllibed.TenantCloudClient.Tests")] diff --git a/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj b/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj index c4353d2..7f8f1f3 100644 --- a/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj +++ b/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj @@ -1,8 +1,9 @@ - + - netstandard2.1 - enable + net8.0;net10.0 + + CS1591 @@ -22,14 +23,14 @@ git MIT tenantcloud - 8.0 - - - ;NU1605;CS8600;CS8602;CS8603 + + + + - + diff --git a/version.json b/version.json new file mode 100644 index 0000000..d2d16de --- /dev/null +++ b/version.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "3.0-dev", + "publicReleaseRefSpec": [ + "^refs/heads/master$", + "^refs/heads/release/.*$", + "^refs/tags/v\\d+\\.\\d+" + ], + "cloudBuild": { + "buildNumber": { + "enabled": true + } + }, + "release": { + "branchName": "release/v{version}", + "versionIncrement": "minor", + "firstUnstableTag": "dev" + } +} From 9a895b3bacc95053bc78bf9c75db863298413ba3 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 12:51:50 -0500 Subject: [PATCH 02/17] Migrate codebase to use file-scoped namespaces, improved serialization contract consistency across converters and models. --- .../HttpMessages/JsonAutoLongConverter.cs | 56 ++---- .../JsonAutoNullableLongConverter.cs | 37 ++++ .../HttpMessages/JsonDecimalConverter.cs | 25 ++- ...JsonStringDateToDateTimeOffsetConverter.cs | 43 ++-- ...ngDateToNullableDateTimeOffsetConverter.cs | 50 ++--- .../HttpMessages/JsonStringToEnumConverter.cs | 29 ++- .../JsonTcTransactionStatusConverter.cs | 43 ++-- .../HttpMessages/TcErrorResponse.cs | 13 +- .../HttpMessages/TcJsonSerializerContext.cs | 18 ++ .../HttpMessages/TcLease.cs | 112 ++++++----- .../HttpMessages/TcLeaseStatus.cs | 27 ++- .../HttpMessages/TcListResponse.cs | 25 ++- .../HttpMessages/TcListResponsePagination.cs | 25 ++- .../HttpMessages/TcLoginRequest.cs | 29 ++- .../HttpMessages/TcLoginResponse.cs | 29 ++- .../HttpMessages/TcPagingListResponse.cs | 53 ++--- .../HttpMessages/TcProperty.cs | 23 ++- .../HttpMessages/TcPropertyAttributes.cs | 21 +- .../HttpMessages/TcTenant.cs | 19 +- .../HttpMessages/TcTenantDetails.cs | 186 ++++++++++-------- .../HttpMessages/TcTransaction.cs | 93 ++++----- .../HttpMessages/TcUnit.cs | 41 ++-- .../HttpMessages/TcUserInfo.cs | 39 ++-- .../HttpMessages/TcUserInfoResponse.cs | 9 +- 24 files changed, 510 insertions(+), 535 deletions(-) create mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoNullableLongConverter.cs create mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoLongConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoLongConverter.cs index 6584481..a3abc01 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoLongConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoLongConverter.cs @@ -1,52 +1,28 @@ -using System; using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class JsonAutoLongConverter : JsonConverter { - public class JsonAutoLongConverter : JsonConverter + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + switch (reader.TokenType) { - switch (reader.TokenType) - { - case JsonTokenType.Number: - return reader.GetInt64(); - case JsonTokenType.String: - var str = reader.GetString(); - return long.Parse(str, NumberFormatInfo.InvariantInfo); - default: - throw new NotSupportedException($"Type {reader.TokenType} not supported"); - } - } - - public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) - { - throw new NotSupportedException(); + case JsonTokenType.Number: + return reader.GetInt64(); + case JsonTokenType.String: + var str = reader.GetString() + ?? throw new NotSupportedException("Null string token not supported"); + return long.Parse(str, NumberFormatInfo.InvariantInfo); + default: + throw new NotSupportedException($"Type {reader.TokenType} not supported"); } } - public class JsonAutoNullableLongConverter : JsonConverter - { - public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - switch (reader.TokenType) - { - case JsonTokenType.Null: - return null; - case JsonTokenType.Number: - return reader.GetInt64(); - case JsonTokenType.String: - var str = reader.GetString(); - return long.Parse(str, NumberFormatInfo.InvariantInfo); - default: - throw new NotSupportedException($"Type {reader.TokenType} not supported"); - } - } - public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options) - { - throw new NotSupportedException(); - } + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value); } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoNullableLongConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoNullableLongConverter.cs new file mode 100644 index 0000000..d81d5ad --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoNullableLongConverter.cs @@ -0,0 +1,37 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class JsonAutoNullableLongConverter : JsonConverter +{ + public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + return null; + case JsonTokenType.Number: + return reader.GetInt64(); + case JsonTokenType.String: + var str = reader.GetString() + ?? throw new NotSupportedException("Null string token not supported"); + return long.Parse(str, NumberFormatInfo.InvariantInfo); + default: + throw new NotSupportedException($"Type {reader.TokenType} not supported"); + } + } + + public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options) + { + if (value.HasValue) + { + writer.WriteNumberValue(value.Value); + } + else + { + writer.WriteNullValue(); + } + } +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonDecimalConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonDecimalConverter.cs index c83e7e0..7904cbd 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonDecimalConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonDecimalConverter.cs @@ -1,23 +1,22 @@ -using System; using System.Text.Json; using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class JsonDecimalConverter : JsonConverter { - public class JsonDecimalConverter : JsonConverter + public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + if (reader.TokenType == JsonTokenType.Number) { - if (reader.TokenType == JsonTokenType.Number) - { - return reader.GetDecimal(); - } - throw new NotSupportedException(); + return reader.GetDecimal(); } - public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) - { - throw new NotSupportedException(); - } + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value); } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs index bf2a948..c09d61a 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs @@ -1,33 +1,32 @@ -using System; using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class JsonStringDateToDateTimeOffsetConverter : JsonConverter { - public class JsonStringDateToDateTimeOffsetConverter : JsonConverter - { - private static readonly string[] _formats = new[] - { - "M/d/yyyy", - "MM/dd/yyyy", - "MM/d/yyyy", - "M/dd/yyyy" - }; + private static readonly string[] Formats = + [ + "M/d/yyyy", + "MM/dd/yyyy", + "MM/d/yyyy", + "M/dd/yyyy", + ]; - public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var str = reader.GetString(); + if (DateTimeOffset.TryParseExact(str, Formats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces, out var dto)) { - var str = reader.GetString(); - if (DateTimeOffset.TryParseExact(str, _formats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces, out var dto)) - { - return dto; - } - throw new NotSupportedException("Unknown Date format for " + str); + return dto; } - public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) - { - throw new NotSupportedException(); - } + throw new NotSupportedException("Unknown Date format for " + str); + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString("M/d/yyyy", CultureInfo.InvariantCulture)); } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToNullableDateTimeOffsetConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToNullableDateTimeOffsetConverter.cs index d0f3c3d..af69bed 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToNullableDateTimeOffsetConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToNullableDateTimeOffsetConverter.cs @@ -1,37 +1,43 @@ -using System; using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class JsonStringDateToNullableDateTimeOffsetConverter : JsonConverter { - public class JsonStringDateToNullableDateTimeOffsetConverter : JsonConverter + private static readonly string[] Formats = + [ + "M/d/yyyy", + "MM/dd/yyyy", + "MM/d/yyyy", + "M/dd/yyyy", + ]; + + public override DateTimeOffset? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - private static readonly string[] _formats = new[] + var str = reader.GetString(); + if (str is null) { - "M/d/yyyy", - "MM/dd/yyyy", - "MM/d/yyyy", - "M/dd/yyyy" - }; + return null; + } - public override DateTimeOffset? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + if (DateTimeOffset.TryParseExact(str, Formats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces, out var dto)) { - var str = reader.GetString(); - if (str == null) - { - return null; - } - if (DateTimeOffset.TryParseExact(str, _formats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces, out var dto)) - { - return dto; - } - throw new NotSupportedException("Unknown Date format for " + str); + return dto; } - public override void Write(Utf8JsonWriter writer, DateTimeOffset? value, JsonSerializerOptions options) + throw new NotSupportedException("Unknown Date format for " + str); + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset? value, JsonSerializerOptions options) + { + if (value is null) { - throw new NotSupportedException(); + writer.WriteNullValue(); + return; } + + writer.WriteStringValue(value.Value.ToString("M/d/yyyy", CultureInfo.InvariantCulture)); } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringToEnumConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringToEnumConverter.cs index a7efddf..f0a0a09 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringToEnumConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringToEnumConverter.cs @@ -1,25 +1,20 @@ -using System; -using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class JsonStringToEnumConverter : JsonConverter + where T : struct, Enum { - public class JsonStringToEnumConverter : JsonConverter + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (typeToConvert.IsEnum) - { - var str = reader.GetString(); - return (T)Enum.Parse(typeToConvert, str, true); - } - throw new NotSupportedException(); - } + var str = reader.GetString() + ?? throw new NotSupportedException("Null string token not supported"); + return Enum.Parse(str, true); + } - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) - { - throw new NotSupportedException(); - } + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString().ToLowerInvariant()); } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcTransactionStatusConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcTransactionStatusConverter.cs index 357ed5b..f257b23 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcTransactionStatusConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcTransactionStatusConverter.cs @@ -1,32 +1,31 @@ -using System; using System.Text.Json; using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class JsonTcTransactionStatusConverter : JsonConverter { - public class JsonTcTransactionStatusConverter : JsonConverter + public override TcTransactionStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - public override TcTransactionStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + switch (reader.TokenType) { - switch (reader.TokenType) - { - case JsonTokenType.Number: - return (TcTransactionStatus) reader.GetByte(); - case JsonTokenType.String: - var str = reader.GetString(); - if (Enum.TryParse(typeof(TcTransactionStatus), str, true, out var result)) - { - return (TcTransactionStatus) result; - } - throw new NotSupportedException($"Unknown status {str}"); - default: - throw new NotSupportedException($"Type {reader.TokenType} not supported"); - } - } + case JsonTokenType.Number: + return (TcTransactionStatus)reader.GetByte(); + case JsonTokenType.String: + var str = reader.GetString(); + if (Enum.TryParse(str, true, out var result)) + { + return result; + } - public override void Write(Utf8JsonWriter writer, TcTransactionStatus value, JsonSerializerOptions options) - { - throw new NotSupportedException(); + throw new NotSupportedException($"Unknown status {str}"); + default: + throw new NotSupportedException($"Type {reader.TokenType} not supported"); } } + + public override void Write(Utf8JsonWriter writer, TcTransactionStatus value, JsonSerializerOptions options) + { + writer.WriteNumberValue((byte)value); + } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcErrorResponse.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcErrorResponse.cs index 5b677a4..ecb324b 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcErrorResponse.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcErrorResponse.cs @@ -1,10 +1,9 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class TcErrorResponse { - public class TcErrorResponse - { - [JsonPropertyName("message")] - public string? Message { get; set; } - } + [JsonPropertyName("message")] + public string? Message { get; set; } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs new file mode 100644 index 0000000..934cd9d --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.HttpMessages; + +[JsonSourceGenerationOptions( + AllowTrailingCommas = true, + PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(TcUserInfoResponse))] +[JsonSerializable(typeof(TcListResponse))] +[JsonSerializable(typeof(TcPagingListResponse))] +[JsonSerializable(typeof(TcListResponse))] +[JsonSerializable(typeof(TcListResponse))] +[JsonSerializable(typeof(TcErrorResponse))] +[JsonSerializable(typeof(TcLoginResponse))] +[JsonSerializable(typeof(TcLoginRequest))] +internal partial class TcJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs index 23461c8..e294fd8 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs @@ -1,79 +1,77 @@ -using System; using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class TcLease { - public class TcLease - { - [JsonPropertyName("id")] - public long Id { get; set; } + [JsonPropertyName("id")] + public long Id { get; set; } - [JsonPropertyName("name")] - public string? Name { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } - [JsonPropertyName("created_at")] - public DateTimeOffset CreationDate { get; set; } + [JsonPropertyName("created_at")] + public DateTimeOffset CreationDate { get; set; } - [JsonPropertyName("rent_from")] - public DateTime StartDate { get; set; } + [JsonPropertyName("rent_from")] + public DateTime StartDate { get; set; } - [JsonPropertyName("rent_to")] - public DateTime? EndDate { get; set; } + [JsonPropertyName("rent_to")] + public DateTime? EndDate { get; set; } - [JsonPropertyName("unit_id")] - public long UnitId { get; set; } + [JsonPropertyName("unit_id")] + public long UnitId { get; set; } - [JsonPropertyName("lease_status")] - public TcLeaseStatus Status { get; set; } + [JsonPropertyName("lease_status")] + public TcLeaseStatus Status { get; set; } - [JsonIgnore] - public bool IsPending => Status == TcLeaseStatus.InsurancePending || Status == TcLeaseStatus.Pending; + [JsonIgnore] + public bool IsPending => Status == TcLeaseStatus.InsurancePending || Status == TcLeaseStatus.Pending; - public bool IsArchived => Status == TcLeaseStatus.Archived; + public bool IsArchived => Status == TcLeaseStatus.Archived; - /// - /// Checks if this lease is currently active. - /// - /// Reference date to use for check. Default to "now" - public bool GetIsActive(DateTimeOffset? referenceDate = null) + /// + /// Checks if this lease is currently active. + /// + /// Reference date to use for check. Default to "now" + public bool GetIsActive(DateTimeOffset? referenceDate = null) + { + if (IsArchived || Status == TcLeaseStatus.NotActive) { - if (IsArchived || Status == TcLeaseStatus.NotActive) - { - return false; - } - - var dto = referenceDate ?? DateTimeOffset.Now; - return Status == TcLeaseStatus.Active && !GetIsFuture(dto) && !GetIsPast(dto); + return false; } - /// - /// Checks if this lease will begin in the future. - /// - /// Reference date to use for check. Default to "now" - public bool GetIsFuture(DateTimeOffset? referenceDate = null) - { - if (IsArchived) - { - return false; - } + var dto = referenceDate ?? DateTimeOffset.Now; + return Status == TcLeaseStatus.Active && !GetIsFuture(dto) && !GetIsPast(dto); + } - var dto = referenceDate ?? DateTimeOffset.Now; - return StartDate > dto; + /// + /// Checks if this lease will begin in the future. + /// + /// Reference date to use for check. Default to "now" + public bool GetIsFuture(DateTimeOffset? referenceDate = null) + { + if (IsArchived) + { + return false; } - /// - /// Checks if this lease is terminated in the past. - /// - /// Reference date to use for check. Default to "now" - public bool GetIsPast(DateTimeOffset? referenceDate = null) - { - if (Status == TcLeaseStatus.Expired || Status == TcLeaseStatus.Archived || Status == TcLeaseStatus.Ended) - { - return true; - } + var dto = referenceDate ?? DateTimeOffset.Now; + return new DateTimeOffset(StartDate) > dto; + } - var dto = referenceDate ?? DateTimeOffset.Now; - return StartDate < dto && EndDate != null && EndDate < dto; + /// + /// Checks if this lease is terminated in the past. + /// + /// Reference date to use for check. Default to "now" + public bool GetIsPast(DateTimeOffset? referenceDate = null) + { + if (Status == TcLeaseStatus.Expired || Status == TcLeaseStatus.Archived || Status == TcLeaseStatus.Ended) + { + return true; } + + var dto = referenceDate ?? DateTimeOffset.Now; + return new DateTimeOffset(StartDate) < dto && EndDate != null && new DateTimeOffset(EndDate.Value) < dto; } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcLeaseStatus.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcLeaseStatus.cs index d34df91..00ee7d1 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLeaseStatus.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcLeaseStatus.cs @@ -1,15 +1,14 @@ -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public enum TcLeaseStatus : byte { - public enum TcLeaseStatus : byte - { - Active = 0, - Archived = 11, - Ended = 4, - Expired = 2, - ExpiresIn = 13, - Future = 1, - InsurancePending = 12, - NotActive = 10, - Pending = 9 - } -} \ No newline at end of file + Active = 0, + Archived = 11, + Ended = 4, + Expired = 2, + ExpiresIn = 13, + Future = 1, + InsurancePending = 12, + NotActive = 10, + Pending = 9, +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponse.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponse.cs index 6fc1d31..65b05af 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponse.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponse.cs @@ -1,17 +1,16 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +/// +/// Response used by /v1 api +/// +/// The type of entries in the list. +public class TcListResponse { - /// - /// Response used by /v1 api - /// - /// - public class TcListResponse - { - [JsonPropertyName("list")] - public T[]? Entries { get; set; } + [JsonPropertyName("list")] + public T[]? Entries { get; set; } - [JsonPropertyName("pagination")] - public TcListResponsePagination? Pagination { get; set; } - } + [JsonPropertyName("pagination")] + public TcListResponsePagination? Pagination { get; set; } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponsePagination.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponsePagination.cs index 687e6e0..c8f4651 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponsePagination.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponsePagination.cs @@ -1,19 +1,18 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class TcListResponsePagination { - public class TcListResponsePagination - { - [JsonPropertyName("current_page")] - public long CurrentPage { get; set; } + [JsonPropertyName("current_page")] + public long CurrentPage { get; set; } - [JsonPropertyName("last_page")] - public long LastPage { get; set; } + [JsonPropertyName("last_page")] + public long LastPage { get; set; } - [JsonPropertyName("per_page")] - public long PerPage { get; set; } + [JsonPropertyName("per_page")] + public long PerPage { get; set; } - [JsonPropertyName("total")] - public long Total { get; set; } - } + [JsonPropertyName("total")] + public long Total { get; set; } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginRequest.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginRequest.cs index 309c77a..d3b7ab3 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginRequest.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginRequest.cs @@ -1,23 +1,22 @@ -using System.Net; +using System.Net; using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +internal class TcLoginRequest { - internal class TcLoginRequest - { - [JsonPropertyName("email")] - public string? Email { get; set; } + [JsonPropertyName("email")] + public string? Email { get; set; } - [JsonPropertyName("password")] - public string? Password { get; set; } + [JsonPropertyName("password")] + public string? Password { get; set; } - [JsonPropertyName("persistent")] - public int IsPersistent { get; set; } = 1; + [JsonPropertyName("persistent")] + public int IsPersistent { get; set; } = 1; - public TcLoginRequest(NetworkCredential netCredentials) - { - Email = netCredentials.UserName; - Password = netCredentials.Password; - } + public TcLoginRequest(NetworkCredential netCredentials) + { + Email = netCredentials.UserName; + Password = netCredentials.Password; } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginResponse.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginResponse.cs index 946e11f..3d4bb57 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginResponse.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginResponse.cs @@ -1,22 +1,21 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class TcLoginResponse { - public class TcLoginResponse - { - [JsonPropertyName("token_type")] - public string? TokenType { get; set; } + [JsonPropertyName("token_type")] + public string? TokenType { get; set; } - [JsonPropertyName("expires_in")] - public ulong? ExpiresIn { get; set; } + [JsonPropertyName("expires_in")] + public ulong? ExpiresIn { get; set; } - [JsonPropertyName("access_token")] - public string? AccessToken { get; set; } + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } - [JsonPropertyName("refresh_token")] - public string? RefreshToken { get; set; } + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } - [JsonPropertyName("user_id")] - public long? UserId { get; set; } - } + [JsonPropertyName("user_id")] + public long? UserId { get; set; } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListResponse.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListResponse.cs index 424233e..2f4b369 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListResponse.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListResponse.cs @@ -1,45 +1,16 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages -{ - /// - /// Response use by /v2 api - /// - /// - internal class TcPagingListResponse - { - [JsonPropertyName("data")] - public T[]? Entries { get; set; } - - [JsonPropertyName("meta")] - public TcPagingListMeta? Meta { get; set; } - } - - internal class TcPagingListMeta - { - [JsonPropertyName("pagination")] - public TcPagingListMetaPagination? Pagination { get; set; } - - [JsonPropertyName("units_count")] - public long UnitsCount { get; set; } - - } +namespace Yllibed.TenantCloudClient.HttpMessages; - internal class TcPagingListMetaPagination - { - [JsonPropertyName("count")] - public long Count { get; set; } - - [JsonPropertyName("current_page")] - public long CurrentPage { get; set; } - - [JsonPropertyName("par_page")] - public long PerPage { get; set; } - - [JsonPropertyName("total")] - public long Total { get; set; } +/// +/// Response used by /v2 api +/// +/// The type of entries in the list. +internal class TcPagingListResponse +{ + [JsonPropertyName("data")] + public T[]? Entries { get; set; } - [JsonPropertyName("total_pages")] - public long TotalPages { get; set; } - } + [JsonPropertyName("meta")] + public TcPagingListMeta? Meta { get; set; } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs index 61991b7..a7e50ea 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs @@ -1,19 +1,18 @@ -using System; +using System.Globalization; using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class TcProperty { - public class TcProperty - { - [JsonPropertyName("id")] - [JsonConverter(typeof(JsonAutoLongConverter))] - public long Id { get; set; } + [JsonPropertyName("id")] + [JsonConverter(typeof(JsonAutoLongConverter))] + public long Id { get; set; } - public string? Name => Attributes?.Name; + public string? Name => Attributes?.Name; - public string Address => $"{Attributes?.Address1} {Attributes?.CityAddress}"; + public string Address => string.Format(CultureInfo.InvariantCulture, "{0} {1}", Attributes?.Address1, Attributes?.CityAddress); - [JsonPropertyName("attributes")] - public TcPropertyAttributes? Attributes { get; set; } - } + [JsonPropertyName("attributes")] + public TcPropertyAttributes? Attributes { get; set; } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcPropertyAttributes.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcPropertyAttributes.cs index 62633e1..e49f695 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcPropertyAttributes.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcPropertyAttributes.cs @@ -1,16 +1,15 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class TcPropertyAttributes { - public class TcPropertyAttributes - { - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; - [JsonPropertyName("address1")] - public string Address1 { get; set; } = string.Empty; + [JsonPropertyName("address1")] + public string Address1 { get; set; } = string.Empty; - [JsonPropertyName("cityAddress")] - public string CityAddress { get; set; } = string.Empty; - } + [JsonPropertyName("cityAddress")] + public string CityAddress { get; set; } = string.Empty; } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcTenant.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcTenant.cs index e22758a..fb0e99f 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcTenant.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcTenant.cs @@ -1,14 +1,13 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class TcTenant { - public class TcTenant - { - [JsonPropertyName("id")] - [JsonConverter(typeof(JsonAutoLongConverter))] - public long Id { get; set; } + [JsonPropertyName("id")] + [JsonConverter(typeof(JsonAutoLongConverter))] + public long Id { get; set; } - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - } + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcTenantDetails.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcTenantDetails.cs index 8693d14..5499895 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcTenantDetails.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcTenantDetails.cs @@ -1,138 +1,152 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Text.Json.Serialization; using System.Text.RegularExpressions; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public partial class TcTenantDetails { - public class TcTenantDetails - { - [JsonPropertyName("id")] - [JsonConverter(typeof(JsonAutoLongConverter))] - public long Id { get; set; } + [JsonPropertyName("id")] + [JsonConverter(typeof(JsonAutoLongConverter))] + public long Id { get; set; } - [JsonPropertyName("email")] - public string Email1 { get; set; } = string.Empty; + [JsonPropertyName("email")] + public string Email1 { get; set; } = string.Empty; - [JsonPropertyName("email_2")] - public string? Email2 { get; set; } + [JsonPropertyName("email_2")] + public string? Email2 { get; set; } - [JsonPropertyName("email_3")] - public string? Email3 { get; set; } + [JsonPropertyName("email_3")] + public string? Email3 { get; set; } - public string?[] ValidEmails + public string?[] ValidEmails + { + get { - get + IEnumerable GetEmails() { - IEnumerable GetEmails() + if (IsValidEmail(Email1, out var email1)) + { + yield return email1; + } + + if (IsValidEmail(Email2, out var email2)) { - if (IsValidEmail(Email1, out var email1)) yield return email1; - if (IsValidEmail(Email2, out var email2)) yield return email2; - if (IsValidEmail(Email3, out var email3)) yield return email3; + yield return email2; } - return GetEmails().ToArray(); + if (IsValidEmail(Email3, out var email3)) + { + yield return email3; + } } + + return GetEmails().ToArray(); } + } - public string Emails => string.Join("|", ValidEmails); + public string Emails => string.Join('|', ValidEmails); - [JsonPropertyName("phone")] - public string Phone1 { get; set; } = string.Empty; + [JsonPropertyName("phone")] + public string Phone1 { get; set; } = string.Empty; - [JsonPropertyName("phone_2")] - public string? Phone2 { get; set; } + [JsonPropertyName("phone_2")] + public string? Phone2 { get; set; } - [JsonPropertyName("phone_3")] - public string? Phone3 { get; set; } + [JsonPropertyName("phone_3")] + public string? Phone3 { get; set; } - public string[] ValidPhones + public string[] ValidPhones + { + get { - get + IEnumerable GetPhones() { - IEnumerable GetPhones() + if (IsValidPhone(Phone1, out var phone1)) { - if (IsValidPhone(Phone1, out var phone1)) yield return phone1!; - if (IsValidPhone(Phone2, out var phone2)) yield return phone2!; - if (IsValidPhone(Phone3, out var phone3)) yield return phone3!; + yield return phone1!; } - return GetPhones().ToArray(); + if (IsValidPhone(Phone2, out var phone2)) + { + yield return phone2!; + } + + if (IsValidPhone(Phone3, out var phone3)) + { + yield return phone3!; + } } + + return GetPhones().ToArray(); } + } - public string Phones => string.Join("|", ValidPhones); + public string Phones => string.Join('|', ValidPhones); - public string FirstName { get; set; } = string.Empty; - public string LastName { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; - [JsonPropertyName("status")] - public TcTenantStatus Status { get; set; } + [JsonPropertyName("status")] + public TcTenantStatus Status { get; set; } + [GeneratedRegex( + @"(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|""(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*"")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])", + RegexOptions.CultureInvariant | RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex EmailRegex(); - private static readonly Regex EmailRegex = new Regex( - @"(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|""(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*"")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])", - RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline | RegexOptions.IgnoreCase); + private static bool IsValidEmail(string? s, out string? output) + { + output = null; - private bool IsValidEmail(string? s, out string? output) + if (string.IsNullOrWhiteSpace(s)) { - output = null; + return false; + } - if (string.IsNullOrWhiteSpace(s)) - { - return false; - } + var match = EmailRegex().Match(s); - var match = EmailRegex.Match(s); + if (match.Success) + { + output = match.Value; + return true; + } - if (match.Success) - { - output = match.Value; - return true; - } + return false; + } - return false; - } + [GeneratedRegex( + @"(?:\+)?(?:\d[\-\s\(\)]?){8,15}", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture)] + private static partial Regex PhoneRegex(); - private static readonly Regex PhoneRegex = new Regex( - @"(\+)?(\d[\-\s\(\)]?){8,15}", - RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.CultureInvariant); + private static bool IsValidPhone(string? s, out string? output) + { + output = null; - private bool IsValidPhone(string? s, out string? output) + if (string.IsNullOrWhiteSpace(s)) { - output = null; - - if (string.IsNullOrWhiteSpace(s)) - { - return false; - } + return false; + } - var match = PhoneRegex.Match(s); + var match = PhoneRegex().Match(s); - if (match.Success) + if (match.Success) + { + var chars = new List(match.Groups.Count); + foreach (var c in s) { - var chars = new List(match.Groups.Count); - foreach (var c in s) + if (char.IsDigit(c)) { - if (char.IsDigit(c)) - { - chars.Add(c); - } + chars.Add(c); } - - output = new string(chars.ToArray()); - - return true; } - return false; - } - } + output = new string(chars.ToArray()); - public enum TcTenantStatus: byte - { + return true; + } + return false; } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs index 84769df..5aabf85 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs @@ -1,75 +1,52 @@ -using System; using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages -{ - public class TcTransaction - { - [JsonPropertyName("id")] - [JsonConverter(typeof(JsonAutoLongConverter))] - public long Id { get; set; } - - [JsonPropertyName("unit_id")] - [JsonConverter(typeof(JsonAutoNullableLongConverter))] - public long? UnitId { get; set; } +namespace Yllibed.TenantCloudClient.HttpMessages; - [JsonPropertyName("property_id")] - [JsonConverter(typeof(JsonAutoNullableLongConverter))] - public long? PropertyId { get; set; } +public class TcTransaction +{ + [JsonPropertyName("id")] + [JsonConverter(typeof(JsonAutoLongConverter))] + public long Id { get; set; } - [JsonPropertyName("detail")] - public string? Detail { get; set; } + [JsonPropertyName("unit_id")] + [JsonConverter(typeof(JsonAutoNullableLongConverter))] + public long? UnitId { get; set; } - [JsonPropertyName("is_recurring")] - public bool IsRecurring { get; set; } + [JsonPropertyName("property_id")] + [JsonConverter(typeof(JsonAutoNullableLongConverter))] + public long? PropertyId { get; set; } - [JsonPropertyName("created_at")] - public DateTimeOffset CreatedAt { get; set; } + [JsonPropertyName("detail")] + public string? Detail { get; set; } - [JsonConverter(typeof(JsonStringDateToDateTimeOffsetConverter))] - [JsonPropertyName("date")] - public DateTimeOffset DueDate { get; set; } + [JsonPropertyName("is_recurring")] + public bool IsRecurring { get; set; } - [JsonConverter(typeof(JsonDecimalConverter))] - public decimal Amount { get; set; } + [JsonPropertyName("created_at")] + public DateTimeOffset CreatedAt { get; set; } - [JsonConverter(typeof(JsonDecimalConverter))] - public decimal Paid { get; set; } + [JsonConverter(typeof(JsonStringDateToDateTimeOffsetConverter))] + [JsonPropertyName("date")] + public DateTimeOffset DueDate { get; set; } - [JsonConverter(typeof(JsonDecimalConverter))] - public decimal Balance { get; set; } + [JsonConverter(typeof(JsonDecimalConverter))] + public decimal Amount { get; set; } - public string Currency { get; set; } = string.Empty; + [JsonConverter(typeof(JsonDecimalConverter))] + public decimal Paid { get; set; } - [JsonConverter(typeof(JsonStringDateToNullableDateTimeOffsetConverter))] - [JsonPropertyName("paid_at")] - public DateTimeOffset? PaidAt { get; set; } + [JsonConverter(typeof(JsonDecimalConverter))] + public decimal Balance { get; set; } - [JsonConverter(typeof(JsonStringToEnumConverter))] - public TcTransactionCategory Category { get; set; } + public string Currency { get; set; } = string.Empty; - [JsonConverter(typeof(JsonTcTransactionStatusConverter))] - public TcTransactionStatus Status { get; set; } - } + [JsonConverter(typeof(JsonStringDateToNullableDateTimeOffsetConverter))] + [JsonPropertyName("paid_at")] + public DateTimeOffset? PaidAt { get; set; } - public enum TcTransactionCategory : byte - { - Income, - Expense, - Refund, - Credits, - liability - } + [JsonConverter(typeof(JsonStringToEnumConverter))] + public TcTransactionCategory Category { get; set; } - public enum TcTransactionStatus : byte - { - Due = 0, - Paid = 1, - Partial=2, - Pending=3, - Void = 9, - WithBalance, // with_balance - Overdue, // overdue - Waive, // waive - } + [JsonConverter(typeof(JsonTcTransactionStatusConverter))] + public TcTransactionStatus Status { get; set; } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs index 7f29630..a4eff4f 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs @@ -1,32 +1,31 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class TcUnit { - public class TcUnit - { - [JsonPropertyName("id")] - [JsonConverter(typeof(JsonAutoLongConverter))] - public long Id { get; set; } + [JsonPropertyName("id")] + [JsonConverter(typeof(JsonAutoLongConverter))] + public long Id { get; set; } - [JsonPropertyName("property_id")] - public long PropertyId { get; set; } + [JsonPropertyName("property_id")] + public long PropertyId { get; set; } - public string Name { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; - public string? Description { get; set; } + public string? Description { get; set; } - public decimal Price { get; set; } + public decimal Price { get; set; } - [JsonPropertyName("is_rented")] - public bool IsRented { get; set; } + [JsonPropertyName("is_rented")] + public bool IsRented { get; set; } - [JsonPropertyName("pets_allowed")] - public bool IsPetAllowed { get; set; } + [JsonPropertyName("pets_allowed")] + public bool IsPetAllowed { get; set; } - [JsonPropertyName("is_furnished")] - public bool IsFurnished { get; set; } + [JsonPropertyName("is_furnished")] + public bool IsFurnished { get; set; } - [JsonPropertyName("is_utilities")] - public bool IsUtilities { get; set; } - } + [JsonPropertyName("is_utilities")] + public bool IsUtilities { get; set; } } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcUserInfo.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcUserInfo.cs index a1289ea..f768543 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcUserInfo.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcUserInfo.cs @@ -1,24 +1,21 @@ -using System.Text.Json.Serialization; +namespace Yllibed.TenantCloudClient.HttpMessages; -namespace Yllibed.TenantCloudClient.HttpMessages +public class TcUserInfo { - public class TcUserInfo - { - public long Id { get; set; } - public string SubDomain { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - public string FirstName { get; set; } = string.Empty; - public string LastName { get; set; } = string.Empty; - public string Address1 { get; set; } = string.Empty; - public string Address2 { get; set; } = string.Empty; - public string City { get; set; } = string.Empty; - public string State { get; set; } = string.Empty; - public string Zip { get; set; } = string.Empty; - public bool IsCompany { get; set; } - public string Company { get; set; } = string.Empty; - public string Phone { get; set; } = string.Empty; - public string Fax { get; set; } = string.Empty; - public string Lang { get; set; } = string.Empty; - public string IsVip { get; set; } = string.Empty; - } + public long Id { get; set; } + public string SubDomain { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string Address1 { get; set; } = string.Empty; + public string Address2 { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string State { get; set; } = string.Empty; + public string Zip { get; set; } = string.Empty; + public bool IsCompany { get; set; } + public string Company { get; set; } = string.Empty; + public string Phone { get; set; } = string.Empty; + public string Fax { get; set; } = string.Empty; + public string Lang { get; set; } = string.Empty; + public string IsVip { get; set; } = string.Empty; } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcUserInfoResponse.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcUserInfoResponse.cs index 088c52c..a0f4f2d 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcUserInfoResponse.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcUserInfoResponse.cs @@ -1,7 +1,6 @@ -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class TcUserInfoResponse { - public class TcUserInfoResponse - { - public TcUserInfo? User { get; set; } - } + public TcUserInfo? User { get; set; } } From 2f9ce1ba60b782169d48c2cf7ae100e9d3c553f3 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 13:15:15 -0500 Subject: [PATCH 03/17] Added initial contract models for TenantCloud API integration: transaction statuses, categories, tenant statuses, and pagination metadata. --- .../HttpMessages/TcPagingListMeta.cs | 12 +++++++++++ .../TcPagingListMetaPagination.cs | 21 +++++++++++++++++++ .../HttpMessages/TcTenantStatus.cs | 5 +++++ .../HttpMessages/TcTransactionCategory.cs | 10 +++++++++ .../HttpMessages/TcTransactionStatus.cs | 13 ++++++++++++ 5 files changed, 61 insertions(+) create mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMeta.cs create mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMetaPagination.cs create mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcTenantStatus.cs create mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcTransactionCategory.cs create mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcTransactionStatus.cs diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMeta.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMeta.cs new file mode 100644 index 0000000..3e4dd17 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMeta.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.HttpMessages; + +internal class TcPagingListMeta +{ + [JsonPropertyName("pagination")] + public TcPagingListMetaPagination? Pagination { get; set; } + + [JsonPropertyName("units_count")] + public long UnitsCount { get; set; } +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMetaPagination.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMetaPagination.cs new file mode 100644 index 0000000..db532aa --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMetaPagination.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.HttpMessages; + +internal class TcPagingListMetaPagination +{ + [JsonPropertyName("count")] + public long Count { get; set; } + + [JsonPropertyName("current_page")] + public long CurrentPage { get; set; } + + [JsonPropertyName("par_page")] + public long PerPage { get; set; } + + [JsonPropertyName("total")] + public long Total { get; set; } + + [JsonPropertyName("total_pages")] + public long TotalPages { get; set; } +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcTenantStatus.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcTenantStatus.cs new file mode 100644 index 0000000..cdda69b --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcTenantStatus.cs @@ -0,0 +1,5 @@ +namespace Yllibed.TenantCloudClient.HttpMessages; + +public enum TcTenantStatus : byte +{ +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcTransactionCategory.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransactionCategory.cs new file mode 100644 index 0000000..fd60e9f --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransactionCategory.cs @@ -0,0 +1,10 @@ +namespace Yllibed.TenantCloudClient.HttpMessages; + +public enum TcTransactionCategory : byte +{ + Income, + Expense, + Refund, + Credits, + Liability, +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcTransactionStatus.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransactionStatus.cs new file mode 100644 index 0000000..c7a3292 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransactionStatus.cs @@ -0,0 +1,13 @@ +namespace Yllibed.TenantCloudClient.HttpMessages; + +public enum TcTransactionStatus : byte +{ + Due = 0, + Paid = 1, + Partial = 2, + Pending = 3, + Void = 9, + WithBalance, + Overdue, + Waive, +} From 07d0054deb8c553a3eb971ce1fbc3f89be09f859 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 13:15:27 -0500 Subject: [PATCH 04/17] Add tests for JSON converters and improve exception messages --- .../Given_JsonConverters.cs | 96 +++++ .../Given_TcClient.cs | 338 ++++++++---------- .../Yllibed.TenantCloudClient.Tests.csproj | 7 +- .../IPaginatedSource.cs | 14 +- src/Yllibed.TenantCloudClient/ITcClient.cs | 31 +- src/Yllibed.TenantCloudClient/ITcContext.cs | 61 ++-- .../InMemoryTcContext.cs | 85 ++--- .../PaginatedSequenceSegment.cs | 22 +- .../PaginatedSource.cs | 95 +++-- .../ReadOnlySequenceExtensions.cs | 23 +- src/Yllibed.TenantCloudClient/TcClient.cs | 246 ++++++------- .../TcClientException.cs | 16 +- .../TcTenantsPaginatedSourceExtensions.cs | 46 ++- ...TcTransactionsPaginatedSourceExtensions.cs | 113 +++--- .../TcUnitsPaginatedSourceExtensions.cs | 46 ++- 15 files changed, 626 insertions(+), 613 deletions(-) create mode 100644 src/Yllibed.TenantCloudClient.Tests/Given_JsonConverters.cs diff --git a/src/Yllibed.TenantCloudClient.Tests/Given_JsonConverters.cs b/src/Yllibed.TenantCloudClient.Tests/Given_JsonConverters.cs new file mode 100644 index 0000000..39d4b28 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Tests/Given_JsonConverters.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using AwesomeAssertions; +using Yllibed.TenantCloudClient.HttpMessages; + +namespace Yllibed.TenantCloudClient.Tests; + +[TestClass] +public class Given_JsonConverters +{ + private static readonly JsonSerializerOptions Options = new() + { + AllowTrailingCommas = true, + PropertyNameCaseInsensitive = true, + }; + + [TestMethod] + public void AutoLongConverter_ReadsNumber() + { + var json = """{"id": 42}"""; + var result = JsonSerializer.Deserialize(json, Options); + + result.Should().NotBeNull(); + result!.Id.Should().Be(42); + } + + [TestMethod] + public void AutoLongConverter_ReadsString() + { + var json = """{"id": "123"}"""; + var result = JsonSerializer.Deserialize(json, Options); + + result.Should().NotBeNull(); + result!.Id.Should().Be(123); + } + + [TestMethod] + public void AutoLongConverter_WritesNumber() + { + var tenant = new TcTenant { Id = 99, Name = "Test" }; + var json = JsonSerializer.Serialize(tenant, Options); + + json.Should().Contain("99"); + } + + [TestMethod] + public void TransactionStatusConverter_ReadsByte() + { + var json = """{"id": 1, "status": 1}"""; + var result = JsonSerializer.Deserialize(json, Options); + + result.Should().NotBeNull(); + result!.Status.Should().Be(TcTransactionStatus.Paid); + } + + [TestMethod] + public void TransactionStatusConverter_ReadsString() + { + var json = """{"id": 1, "status": "Paid"}"""; + var result = JsonSerializer.Deserialize(json, Options); + + result.Should().NotBeNull(); + result!.Status.Should().Be(TcTransactionStatus.Paid); + } + + [TestMethod] + public void DecimalConverter_ReadsNumber() + { + var json = """{"id": 1, "amount": 99.50, "paid": 0, "balance": 99.50}"""; + var result = JsonSerializer.Deserialize(json, Options); + + result.Should().NotBeNull(); + result!.Amount.Should().Be(99.50m); + } + + [TestMethod] + public void DateConverter_ReadsDateString() + { + var json = """{"id": 1, "date": "1/15/2024", "amount": 0, "paid": 0, "balance": 0}"""; + var result = JsonSerializer.Deserialize(json, Options); + + result.Should().NotBeNull(); + result!.DueDate.Month.Should().Be(1); + result.DueDate.Day.Should().Be(15); + result.DueDate.Year.Should().Be(2024); + } + + [TestMethod] + public void NullableDateConverter_ReadsNull() + { + var json = """{"id": 1, "paid_at": null, "date": "1/1/2024", "amount": 0, "paid": 0, "balance": 0}"""; + var result = JsonSerializer.Deserialize(json, Options); + + result.Should().NotBeNull(); + result!.PaidAt.Should().BeNull(); + } +} diff --git a/src/Yllibed.TenantCloudClient.Tests/Given_TcClient.cs b/src/Yllibed.TenantCloudClient.Tests/Given_TcClient.cs index d259751..9ab4139 100644 --- a/src/Yllibed.TenantCloudClient.Tests/Given_TcClient.cs +++ b/src/Yllibed.TenantCloudClient.Tests/Given_TcClient.cs @@ -1,193 +1,167 @@ -using System; -using System.Buffers; -using System.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using FluentAssertions; -using FluentAssertions.Execution; -using Microsoft.VisualStudio.TestTools.UnitTesting; +using AwesomeAssertions; using Yllibed.TenantCloudClient.HttpMessages; -namespace Yllibed.TenantCloudClient.Tests +namespace Yllibed.TenantCloudClient.Tests; + +[TestClass] +public class Given_TcClient : TestBase { - [TestClass] - public class Given_TcClient : TestBase + private const string TcUsername = "landlord.test.tc@gmail.com"; + private const string TcPassword = "1234Zxcv"; + private readonly InMemoryTcContext _context = new(TcUsername, TcPassword); + + [TestMethod] + [Ignore("Requires live TenantCloud API")] + public async Task When_GettingUserInfo() { - private const string TC_USERNAME = "landlord.test.tc@gmail.com"; - private const string TC_PASSWORD = "1234Zxcv"; - private readonly InMemoryTcContext _context = new InMemoryTcContext(TC_USERNAME, TC_PASSWORD); - - [TestMethod] - public async Task When_GettingUserInfo() - { - var sut = new TcClient(_context); - var userInfo = await sut.GetUserInfo(CancellationToken.None); - - using var _ = new AssertionScope(); - - userInfo.Should().NotBeNull(); - userInfo.FirstName.Should().NotBeNullOrWhiteSpace(); - userInfo.LastName.Should().NotBeNullOrWhiteSpace(); - userInfo.Id.Should().NotBe(0); - } - - [TestMethod] - public async Task When_GettingAllTenants() - { - var client = new TcClient(_context); - var sut = client.Tenants; - - var all = await sut.GetAll(CancellationToken.None); - - using var _ = new AssertionScope(); - - all.Should().NotBeNull(); - all.Length.Should().NotBe(0); - all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); - } - - [TestMethod] - public async Task When_GettingMovedInTenants() - { - var client = new TcClient(_context); - var sut = client.Tenants.OnlyMovedIn(); - - var all = await sut.GetAll(CancellationToken.None); - - using var _ = new AssertionScope(); - - all.Should().NotBeNull(); - all.Length.Should().NotBe(0); - all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); - } - - [TestMethod] - public async Task When_GettingMNoLeaseTenants() - { - var client = new TcClient(_context); - var sut = client.Tenants.OnlyNoLease(); - - var all = await sut.GetAll(CancellationToken.None); - - using var _ = new AssertionScope(); - - all.Should().NotBeNull(); - // Won't check for zero on this one, since it's normal for it to be zero - all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); - } - - [TestMethod] - public async Task When_GetProperties() - { - var client = new TcClient(_context); - var sut = client.Properties; - - var all = await sut.GetAll(CancellationToken.None); - - all.Should().NotBeNull(); - all.Length.Should().NotBe(0); - all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); - } - - [TestMethod] - public async Task When_GetUnits() - { - var client = new TcClient(_context); - var sut = client.Units; - - var all = await sut.GetAll(CancellationToken.None); - - all.Should().NotBeNull(); - all.Length.Should().NotBe(0); - all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); - } - - [TestMethod] - public async Task When_GetTransactionsForTenant() - { - var client = new TcClient(_context); - var firstTenantId = await GetFirstTenantId(client); - - var sut = client.Transactions - .ForCategory(TcTransactionCategory.Income) - .ForTenant(firstTenantId); - var all = await sut.GetAll(CancellationToken.None); + var sut = new TcClient(_context); + var userInfo = await sut.GetUserInfo(CancellationToken.None); + + userInfo.Should().NotBeNull(); + userInfo!.FirstName.Should().NotBeNullOrWhiteSpace(); + userInfo.LastName.Should().NotBeNullOrWhiteSpace(); + userInfo.Id.Should().NotBe(0); + } + + [TestMethod] + [Ignore("Requires live TenantCloud API")] + public async Task When_GettingAllTenants() + { + var client = new TcClient(_context); + var sut = client.Tenants; + + var all = await sut.GetAll(CancellationToken.None); + + all.Length.Should().NotBe(0); + all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); + } + + [TestMethod] + [Ignore("Requires live TenantCloud API")] + public async Task When_GettingMovedInTenants() + { + var client = new TcClient(_context); + var sut = client.Tenants.OnlyMovedIn(); + + var all = await sut.GetAll(CancellationToken.None); + + all.Length.Should().NotBe(0); + all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); + } + + [TestMethod] + [Ignore("Requires live TenantCloud API")] + public async Task When_GettingMNoLeaseTenants() + { + var client = new TcClient(_context); + var sut = client.Tenants.OnlyNoLease(); + + var all = await sut.GetAll(CancellationToken.None); + + // Won't check for zero on this one, since it's normal for it to be zero + all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); + } + + [TestMethod] + [Ignore("Requires live TenantCloud API")] + public async Task When_GetProperties() + { + var client = new TcClient(_context); + var sut = client.Properties; + + var all = await sut.GetAll(CancellationToken.None); + + all.Length.Should().NotBe(0); + all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); + } - all.Should().NotBeNull(); - all.Length.Should().NotBe(0); - all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); + [TestMethod] + [Ignore("Requires live TenantCloud API")] + public async Task When_GetUnits() + { + var client = new TcClient(_context); + var sut = client.Units; + + var all = await sut.GetAll(CancellationToken.None); - Console.WriteLine($"There is {all.Length} income transactions for tenant {firstTenantId}."); - } + all.Length.Should().NotBe(0); + all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); + } - [TestMethod] - public async Task When_GetTransactionsForUnit() - { - var client = new TcClient(_context); - var firstUnitId = await GetFirstUnitId(client); + [TestMethod] + [Ignore("Requires live TenantCloud API")] + public async Task When_GetTransactionsForTenant() + { + var client = new TcClient(_context); + var firstTenantId = await GetFirstTenantId(client); + + var sut = client.Transactions + .ForCategory(TcTransactionCategory.Income) + .ForTenant(firstTenantId); + var all = await sut.GetAll(CancellationToken.None); + + all.Length.Should().NotBe(0); + all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); + } - var sut = client.Transactions + [TestMethod] + [Ignore("Requires live TenantCloud API")] + public async Task When_GetTransactionsForUnit() + { + var client = new TcClient(_context); + var firstUnitId = await GetFirstUnitId(client); + + var sut = client.Transactions + .ForCategory(TcTransactionCategory.Income) + .ForUnit(firstUnitId); + var all = await sut.GetAll(CancellationToken.None); + + all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); + } + + [TestMethod] + [Ignore("Requires live TenantCloud API")] + public async Task When_GetExpenseTransactions() + { + var client = new TcClient(_context); + + var sut = client.Transactions + .ForCategory(TcTransactionCategory.Expense); + var all = await sut.GetAll(CancellationToken.None); + + all.Length.Should().NotBe(0); + all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); + } + + [TestMethod] + [Ignore("Requires live TenantCloud API")] + public async Task When_GetBalancePerProperty() + { + var client = new TcClient(_context); + + var all = (await client.Transactions .ForCategory(TcTransactionCategory.Income) - .ForUnit(firstUnitId); - var all = await sut.GetAll(CancellationToken.None); - - all.Should().NotBeNull(); - //all.Length.Should().NotBe(0); - all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); - - Console.WriteLine($"There is {all.Length} income transactions for unit {firstUnitId}."); - } - - [TestMethod] - public async Task When_GetExpenseTransactions() - { - var client = new TcClient(_context); - - var sut = client.Transactions - .ForCategory(TcTransactionCategory.Expense); - var all = await sut.GetAll(CancellationToken.None); - - all.Should().NotBeNull(); - all.Length.Should().NotBe(0); - all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); - - Console.WriteLine($"There is {all.Length} expense transactions."); - } - - [TestMethod] - public async Task When_GetBalancePerProperty() - { - var client = new TcClient(_context); - - var all = (await client.Transactions - .ForCategory(TcTransactionCategory.Income) - .ForStatus(TcTransactionStatus.WithBalance) - .GetAll(CancellationToken.None)) - .AsEnumerable() - .Where(t => t.PropertyId != null) // only property-specific income - .GroupBy(t => (long)t.PropertyId, t => t.Balance) // group them - .Select(g => (propertyId: g.Key, balance: g.Sum())) // summarize - .ToArray(); // create final array - - all.Should().NotBeNull(); - all.Length.Should().NotBe(0); - all.AsEnumerable().Select(x => x.propertyId).Should().OnlyHaveUniqueItems(); - - } - - private static async Task GetFirstTenantId(TcClient client) - { - var tenants = await client.Tenants.OnlyMovedIn().GetAll(CancellationToken.None, 1); - var firstTenantId = tenants.Slice(0, 1).ToArray()[0].Id; - return firstTenantId; - } - - private static async Task GetFirstUnitId(TcClient client) - { - var units = await client.Units.OnlyOccuped().GetAll(CancellationToken.None, 1); - var firstUnitId = units.Slice(0, 1).ToArray()[0].Id; - return firstUnitId; - } + .ForStatus(TcTransactionStatus.WithBalance) + .GetAll(CancellationToken.None)) + .AsEnumerable() + .Where(t => t.PropertyId != null) + .GroupBy(t => (long)t.PropertyId!, t => t.Balance) + .Select(g => (propertyId: g.Key, balance: g.Sum())) + .ToArray(); + + all.Length.Should().NotBe(0); + all.AsEnumerable().Select(x => x.propertyId).Should().OnlyHaveUniqueItems(); + } + + private static async Task GetFirstTenantId(TcClient client) + { + var tenants = await client.Tenants.OnlyMovedIn().GetAll(CancellationToken.None, 1).ConfigureAwait(false); + return tenants.AsEnumerable().First().Id; + } + + private static async Task GetFirstUnitId(TcClient client) + { + var units = await client.Units.OnlyOccuped().GetAll(CancellationToken.None, 1).ConfigureAwait(false); + return units.AsEnumerable().First().Id; } } diff --git a/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj b/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj index e1f9746..c468b42 100644 --- a/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj +++ b/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj @@ -7,12 +7,7 @@ - - - - - - + diff --git a/src/Yllibed.TenantCloudClient/IPaginatedSource.cs b/src/Yllibed.TenantCloudClient/IPaginatedSource.cs index 5821de7..543733d 100644 --- a/src/Yllibed.TenantCloudClient/IPaginatedSource.cs +++ b/src/Yllibed.TenantCloudClient/IPaginatedSource.cs @@ -1,14 +1,10 @@ -using System; using System.Buffers; -using System.Threading; -using System.Threading.Tasks; -namespace Yllibed.TenantCloudClient +namespace Yllibed.TenantCloudClient; + +public interface IPaginatedSource { - public interface IPaginatedSource - { - Task<(ReadOnlyMemory entries, long pageNo, long totalEntries)> GetPage(CancellationToken ct, long pageNo = 1); + Task<(ReadOnlyMemory entries, long pageNo, long totalEntries)> GetPage(CancellationToken ct, long pageNo = 1); - Task> GetAll(CancellationToken ct, long maxResults = 300); - } + Task> GetAll(CancellationToken ct, long maxResults = 300); } diff --git a/src/Yllibed.TenantCloudClient/ITcClient.cs b/src/Yllibed.TenantCloudClient/ITcClient.cs index 5241555..82505ec 100644 --- a/src/Yllibed.TenantCloudClient/ITcClient.cs +++ b/src/Yllibed.TenantCloudClient/ITcClient.cs @@ -1,29 +1,22 @@ -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; using Yllibed.TenantCloudClient.HttpMessages; -namespace Yllibed.TenantCloudClient +namespace Yllibed.TenantCloudClient; + +/// +/// Represents a client to send queries to TC server +/// +public interface ITcClient { /// - /// Represents a client to send queries to TC server + /// Get information about current signed-in user /// - public interface ITcClient - { - /// - /// Get information about current signed-in user - /// - /// - /// - Task GetUserInfo(CancellationToken ct); + Task GetUserInfo(CancellationToken ct); - IPaginatedSource Tenants { get; } + IPaginatedSource Tenants { get; } - IPaginatedSource Properties { get; } + IPaginatedSource Properties { get; } - IPaginatedSource Units { get; } + IPaginatedSource Units { get; } - IPaginatedSource Transactions { get; } - } + IPaginatedSource Transactions { get; } } diff --git a/src/Yllibed.TenantCloudClient/ITcContext.cs b/src/Yllibed.TenantCloudClient/ITcContext.cs index cbe4894..fabf0f5 100644 --- a/src/Yllibed.TenantCloudClient/ITcContext.cs +++ b/src/Yllibed.TenantCloudClient/ITcContext.cs @@ -1,39 +1,36 @@ -using System.Net; -using System.Threading; -using System.Threading.Tasks; +using System.Net; -namespace Yllibed.TenantCloudClient +namespace Yllibed.TenantCloudClient; + +/// +/// This is the interface the application must implement to use the API. +/// +public interface ITcContext { /// - /// This is the interface the application must implement to use the API. + /// This method will be called by TcClient when it is required to + /// login again to update the auth token. /// - public interface ITcContext - { - /// - /// This method will be called by TcClient when it is required to - /// login again to update the auth token. - /// - /// - /// Properties `UserName` and `Password` are used for authentication. - /// - Task GetCredentials(CancellationToken ct); + /// + /// Properties UserName and Password are used for authentication. + /// + Task GetCredentials(CancellationToken ct); - /// - /// This method will be called by TcClient to persist the auth token. - /// - /// - /// If you can, it should be persisted in a durable manner: like on disk. - /// You should treat this as the same security sensitivity as credentials. - /// - Task SetAuthToken(CancellationToken ct, string token); + /// + /// This method will be called by TcClient to persist the auth token. + /// + /// + /// If you can, it should be persisted in a durable manner: like on disk. + /// You should treat this as the same security sensitivity as credentials. + /// + Task SetAuthToken(CancellationToken ct, string token); - /// - /// This method will be called by TcClient to get the persisted token. - /// - /// - /// TcClient won't cache it and will call this method each time the token - /// is required. You should have a mechanism to serve it from cached memory. - /// - Task GetAuthToken(CancellationToken ct); - } + /// + /// This method will be called by TcClient to get the persisted token. + /// + /// + /// TcClient won't cache it and will call this method each time the token + /// is required. You should have a mechanism to serve it from cached memory. + /// + Task GetAuthToken(CancellationToken ct); } diff --git a/src/Yllibed.TenantCloudClient/InMemoryTcContext.cs b/src/Yllibed.TenantCloudClient/InMemoryTcContext.cs index f178003..60acd14 100644 --- a/src/Yllibed.TenantCloudClient/InMemoryTcContext.cs +++ b/src/Yllibed.TenantCloudClient/InMemoryTcContext.cs @@ -1,60 +1,53 @@ -using System; using System.Net; -using System.Threading; -using System.Threading.Tasks; -namespace Yllibed.TenantCloudClient +namespace Yllibed.TenantCloudClient; + +/// +/// Naive TCContext implementation storing the token in memory. +/// +public class InMemoryTcContext : ITcContext { - /// - /// Naive TCContext implementation storing the token in memory. - /// - public class InMemoryTcContext : ITcContext - { - private readonly Func> _credentials; - private string? _token; + private readonly Func> _credentials; + private string? _token; - public InMemoryTcContext(Func> asyncCredentialsCallback) - { - _credentials = asyncCredentialsCallback; - } + public InMemoryTcContext(Func> asyncCredentialsCallback) + { + _credentials = asyncCredentialsCallback; + } - public InMemoryTcContext(Func> asyncCredentialsCallback) - { - _credentials = async ct => - { - var (username, password) = await asyncCredentialsCallback(ct); - return new NetworkCredential(username, password); - }; - } - - public InMemoryTcContext(Func credentialsCallback) + public InMemoryTcContext(Func> asyncCredentialsCallback) + { + _credentials = async ct => { - _credentials = _ => Task.FromResult(credentialsCallback()); - } + var (username, password) = await asyncCredentialsCallback(ct).ConfigureAwait(false); + return new NetworkCredential(username, password); + }; + } - public InMemoryTcContext(NetworkCredential credentials) - { - _credentials = _ => Task.FromResult(credentials); - } + public InMemoryTcContext(Func credentialsCallback) + { + _credentials = _ => Task.FromResult(credentialsCallback()); + } - public InMemoryTcContext(string username, string password) - { - var credentials = new NetworkCredential(username, password); - _credentials = _ => Task.FromResult(credentials); - } + public InMemoryTcContext(NetworkCredential credentials) + { + _credentials = _ => Task.FromResult(credentials); + } - public Task GetCredentials(CancellationToken ct) => _credentials(ct); + public InMemoryTcContext(string username, string password) + { + var credentials = new NetworkCredential(username, password); + _credentials = _ => Task.FromResult(credentials); + } - public Task SetAuthToken(CancellationToken ct, string token) - { - _token = token; + public Task GetCredentials(CancellationToken ct) => _credentials(ct); - return Task.CompletedTask; - } + public Task SetAuthToken(CancellationToken ct, string token) + { + _token = token; - public Task GetAuthToken(CancellationToken ct) - { - return Task.FromResult(_token); - } + return Task.CompletedTask; } + + public Task GetAuthToken(CancellationToken ct) => Task.FromResult(_token); } diff --git a/src/Yllibed.TenantCloudClient/PaginatedSequenceSegment.cs b/src/Yllibed.TenantCloudClient/PaginatedSequenceSegment.cs index 81dc63c..d0d20d7 100644 --- a/src/Yllibed.TenantCloudClient/PaginatedSequenceSegment.cs +++ b/src/Yllibed.TenantCloudClient/PaginatedSequenceSegment.cs @@ -1,19 +1,17 @@ -using System; using System.Buffers; -namespace Yllibed.TenantCloudClient +namespace Yllibed.TenantCloudClient; + +internal class PaginatedSequenceSegment : ReadOnlySequenceSegment { - internal class PaginatedSequenceSegment : ReadOnlySequenceSegment + internal PaginatedSequenceSegment(ReadOnlyMemory memory, long index) { - internal PaginatedSequenceSegment(ReadOnlyMemory memory, long index) - { - Memory = memory; - RunningIndex = index; - } + Memory = memory; + RunningIndex = index; + } - internal void SetNext(PaginatedSequenceSegment next) - { - Next = next; - } + internal void SetNext(PaginatedSequenceSegment next) + { + Next = next; } } diff --git a/src/Yllibed.TenantCloudClient/PaginatedSource.cs b/src/Yllibed.TenantCloudClient/PaginatedSource.cs index 6063bc2..020b3aa 100644 --- a/src/Yllibed.TenantCloudClient/PaginatedSource.cs +++ b/src/Yllibed.TenantCloudClient/PaginatedSource.cs @@ -1,68 +1,63 @@ -using System; using System.Buffers; -using System.Threading; -using System.Threading.Tasks; -namespace Yllibed.TenantCloudClient -{ - internal class PaginatedSource : IPaginatedSource - { - private readonly Func, long, long)>> _pageGetter; - private readonly string _extraUrl; +namespace Yllibed.TenantCloudClient; - public PaginatedSource(Func, long, long)>> pageGetter, string extraUrl) - { - _pageGetter = pageGetter; - _extraUrl = extraUrl; - } +internal class PaginatedSource : IPaginatedSource +{ + private readonly Func, long, long)>> _pageGetter; + private readonly string _extraUrl; - public Task<(ReadOnlyMemory entries, long pageNo, long totalEntries)> GetPage(CancellationToken ct, long pageNo = 1) - { - return _pageGetter(ct, pageNo, _extraUrl); - } + public PaginatedSource(Func, long, long)>> pageGetter, string extraUrl) + { + _pageGetter = pageGetter; + _extraUrl = extraUrl; + } - public async Task> GetAll(CancellationToken ct, long maxResults) - { - var pageNo = 1; + public Task<(ReadOnlyMemory entries, long pageNo, long totalEntries)> GetPage(CancellationToken ct, long pageNo = 1) + { + return _pageGetter(ct, pageNo, _extraUrl); + } - PaginatedSequenceSegment? first = null; - PaginatedSequenceSegment? last = null; + public async Task> GetAll(CancellationToken ct, long maxResults = 300) + { + var pageNo = 1; - var index = 0; + PaginatedSequenceSegment? first = null; + PaginatedSequenceSegment? last = null; - while (!ct.IsCancellationRequested && index <= maxResults) - { - var (entries, _, totalEntries) = await GetPage(ct, pageNo++); + var index = 0; - var segment = new PaginatedSequenceSegment(entries, index); - index += entries.Length; + while (!ct.IsCancellationRequested && index <= maxResults) + { + var (entries, _, totalEntries) = await GetPage(ct, pageNo++).ConfigureAwait(false); - if (first == null) - { - first = segment; - last = segment; - } - else - { - last?.SetNext(segment); - last = segment; - } + var segment = new PaginatedSequenceSegment(entries, index); + index += entries.Length; - if (index >= totalEntries) - { - break; // finished - } + if (first is null) + { + first = segment; + last = segment; + } + else + { + last!.SetNext(segment); + last = segment; } - return first == null - ? ReadOnlySequence.Empty - : new ReadOnlySequence(first, 0, last, (int)last!.Memory.Length); + if (index >= totalEntries) + { + break; // finished + } } + return first is null + ? ReadOnlySequence.Empty + : new ReadOnlySequence(first, 0, last!, (int)last!.Memory.Length); + } - internal PaginatedSource ProjectedWithExtraUrl(Func extraUrlUpdater) - { - return new PaginatedSource(_pageGetter, extraUrlUpdater(_extraUrl)); - } + internal PaginatedSource ProjectedWithExtraUrl(Func extraUrlUpdater) + { + return new PaginatedSource(_pageGetter, extraUrlUpdater(_extraUrl)); } } diff --git a/src/Yllibed.TenantCloudClient/ReadOnlySequenceExtensions.cs b/src/Yllibed.TenantCloudClient/ReadOnlySequenceExtensions.cs index 9abb5ad..4130710 100644 --- a/src/Yllibed.TenantCloudClient/ReadOnlySequenceExtensions.cs +++ b/src/Yllibed.TenantCloudClient/ReadOnlySequenceExtensions.cs @@ -1,23 +1,20 @@ -using System; using System.Buffers; -using System.Collections.Generic; -namespace Yllibed.TenantCloudClient +namespace Yllibed.TenantCloudClient; + +public static class ReadOnlySequenceExtensions { - public static class ReadOnlySequenceExtensions + public static IEnumerable AsEnumerable(this ReadOnlySequence source) { - public static IEnumerable AsEnumerable(this ReadOnlySequence source) + var enumerator = source.GetEnumerator(); + + while (enumerator.MoveNext()) { - var enumerator = source.GetEnumerator(); + var items = enumerator.Current.ToArray(); - while (enumerator.MoveNext()) + foreach (var item in items) { - var items = enumerator.Current.ToArray(); - - foreach (var item in items) - { - yield return item; - } + yield return item; } } } diff --git a/src/Yllibed.TenantCloudClient/TcClient.cs b/src/Yllibed.TenantCloudClient/TcClient.cs index c5266ce..d18152e 100644 --- a/src/Yllibed.TenantCloudClient/TcClient.cs +++ b/src/Yllibed.TenantCloudClient/TcClient.cs @@ -1,178 +1,166 @@ -using System; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; +using System.Text.Json.Serialization.Metadata; using Yllibed.TenantCloudClient.HttpMessages; -namespace Yllibed.TenantCloudClient +namespace Yllibed.TenantCloudClient; + +public class TcClient : IDisposable, ITcClient { - public class TcClient : IDisposable, ITcClient + private readonly ITcContext _context; + private readonly HttpClient _httpClient; + + private static readonly Encoding _encoding = new UTF8Encoding(false); + + public TcClient(ITcContext context) { - private readonly ITcContext _context; - private readonly HttpClient _httpClient; + _context = context; - private static readonly Encoding _encoding = new UTF8Encoding(false); + Tenants = new PaginatedSource(GetTenantPage, ""); - public TcClient(ITcContext context) - { - _context = context; + Properties = new PaginatedSource(GetPropertyPage, ""); - Tenants = new PaginatedSource(GetTenantPage, ""); + Units = new PaginatedSource(GetUnitsPage, ""); - Properties = new PaginatedSource(GetPropertyPage, ""); + Transactions = new PaginatedSource(GetTransactionsPage, ""); - Units = new PaginatedSource(GetUnitsPage, ""); + var httpHandler = new HttpClientHandler() + { + UseCookies = false, + UseDefaultCredentials = false, + AllowAutoRedirect = true, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + }; - Transactions = new PaginatedSource(GetTransactionsPage, ""); + _httpClient = new HttpClient(httpHandler, true) + { + BaseAddress = new Uri("https://home.tenantcloud.com/"), + }; + + _httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Yllibed.TenantCloudClient", "0.1")); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/json")); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain")); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/*")); + } - var httpHandler = new HttpClientHandler() - { - UseCookies = false, - UseDefaultCredentials = false, - AllowAutoRedirect = true, - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, - }; + public async Task GetUserInfo(CancellationToken ct) + { + var result = await HttpGet(ct, "v1/auth/user", TcJsonSerializerContext.Default.TcUserInfoResponse).ConfigureAwait(false); + return result?.User; + } - _httpClient = new HttpClient(httpHandler, true) - { - BaseAddress = new Uri("https://home.tenantcloud.com/") - }; - - _httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Yllibed.TenantCloudClient", "0.1")); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/json")); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain")); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/*")); - } + public IPaginatedSource Tenants { get; } - public async Task GetUserInfo(CancellationToken ct) - { - var result = await HttpGet(ct, "v1/auth/user"); - return result?.User; - } + private async Task<(ReadOnlyMemory, long, long)> GetTenantPage(CancellationToken ct, long pageNo, string extraUrl) + { + var response = await HttpGet(ct, "v1/landlord/tenants?page=" + pageNo.ToString(System.Globalization.CultureInfo.InvariantCulture) + extraUrl, TcJsonSerializerContext.Default.TcListResponseTcTenantDetails).ConfigureAwait(false); + var memory = new Memory(response.Entries); + return (memory, pageNo, response?.Pagination?.Total ?? 0); + } + public IPaginatedSource Properties { get; } - public IPaginatedSource Tenants { get; } + private async Task<(ReadOnlyMemory, long, long)> GetPropertyPage(CancellationToken ct, long pageNo, string extraUrl) + { + var response = await HttpGet(ct, "v2/property?fields[property]=name,property_status,address1,cityAddress&page=" + pageNo.ToString(System.Globalization.CultureInfo.InvariantCulture) + extraUrl, TcJsonSerializerContext.Default.TcPagingListResponseTcProperty).ConfigureAwait(false); + var memory = new Memory(response.Entries); + return (memory, pageNo, response?.Meta?.Pagination?.Total ?? 0); + } - private async Task<(ReadOnlyMemory, long, long)> GetTenantPage(CancellationToken ct, long pageNo, string extraUrl) - { - var response = await HttpGet>(ct, "v1/landlord/tenants?page=" + pageNo + extraUrl); - var memory = new Memory(response.Entries); - return (memory, pageNo, response?.Pagination?.Total ?? 0); - } + public IPaginatedSource Units { get; } - public IPaginatedSource Properties { get; } + private async Task<(ReadOnlyMemory, long, long)> GetUnitsPage(CancellationToken ct, long pageNo, string extraUrl) + { + var response = await HttpGet(ct, "v1/landlord/units?page=" + pageNo.ToString(System.Globalization.CultureInfo.InvariantCulture) + extraUrl, TcJsonSerializerContext.Default.TcListResponseTcUnit).ConfigureAwait(false); + var memory = new Memory(response.Entries); + return (memory, pageNo, response?.Pagination?.Total ?? 0); + } - private async Task<(ReadOnlyMemory, long, long)> GetPropertyPage(CancellationToken ct, long pageNo, string extraUrl) - { - var response = await HttpGet>(ct, "v2/property?fields[property]=name,property_status,address1,cityAddress&page=" + pageNo + extraUrl); - var memory = new Memory(response.Entries); - return (memory, pageNo, response?.Meta?.Pagination?.Total ?? 0); - } + public IPaginatedSource Transactions { get; } - public IPaginatedSource Units { get; } + private async Task<(ReadOnlyMemory, long, long)> GetTransactionsPage(CancellationToken ct, long pageNo, string extraUrl) + { + var response = await HttpGet(ct, "v1/landlord/transactions?page=" + pageNo.ToString(System.Globalization.CultureInfo.InvariantCulture) + extraUrl, TcJsonSerializerContext.Default.TcListResponseTcTransaction).ConfigureAwait(false); + var memory = new Memory(response.Entries); + return (memory, pageNo, response?.Pagination?.Total ?? 0); + } - private async Task<(ReadOnlyMemory, long, long)> GetUnitsPage(CancellationToken ct, long pageNo, string extraUrl) + private async Task HttpGet(CancellationToken ct, string uri, JsonTypeInfo typeInfo) + { + var req = new HttpRequestMessage(HttpMethod.Get, uri); + using var response = await HttpSend(ct, req).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + await using var _ = stream.ConfigureAwait(false); + + if (response.IsSuccessStatusCode) { - var response = await HttpGet>(ct, "v1/landlord/units?page=" + pageNo + extraUrl); - var memory = new Memory(response.Entries); - return (memory, pageNo, response?.Pagination?.Total ?? 0); + var payload = await JsonSerializer.DeserializeAsync(stream, typeInfo, ct).ConfigureAwait(false); + return payload ?? throw new TcClientException(response.StatusCode, "Null response payload"); } - - public IPaginatedSource Transactions { get; } - - private async Task<(ReadOnlyMemory, long, long)> GetTransactionsPage(CancellationToken ct, long pageNo, string extraUrl) + else { - var response = await HttpGet>(ct, "v1/landlord/transactions?page=" + pageNo + extraUrl); - var memory = new Memory(response.Entries); - return (memory, pageNo, response?.Pagination?.Total ?? 0); + var errorPayload = await JsonSerializer.DeserializeAsync(stream, TcJsonSerializerContext.Default.TcErrorResponse, ct).ConfigureAwait(false); + throw new TcClientException(response.StatusCode, errorPayload?.Message ?? "Http error"); } + } - private static readonly JsonSerializerOptions _jsonOptions = - new JsonSerializerOptions - { - AllowTrailingCommas = true, - PropertyNameCaseInsensitive = true - }; + private async Task HttpSend(CancellationToken ct, HttpRequestMessage request) + { + var token = await _context.GetAuthToken(ct).ConfigureAwait(false); - private async Task HttpGet(CancellationToken ct, string uri) + if (!string.IsNullOrEmpty(token)) { - var req = new HttpRequestMessage(HttpMethod.Get, uri); - using var response = await HttpSend(ct, req); - await using var stream = await response.Content.ReadAsStreamAsync(); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); - if (response.IsSuccessStatusCode) + if (response.StatusCode != HttpStatusCode.Unauthorized) { - var payload = await JsonSerializer.DeserializeAsync(stream, _jsonOptions, ct); - return payload; + return response; } - else - { - var errorPayload = await JsonSerializer.DeserializeAsync(stream, _jsonOptions, ct); - throw new TcClientException(response.StatusCode, errorPayload?.Message ?? "Http error"); - } + request.Headers.Authorization = null; } - private async Task HttpSend(CancellationToken ct, HttpRequestMessage request) + var loginRequest = new TcLoginRequest(await _context.GetCredentials(ct).ConfigureAwait(false)); + var loginRequestMsg = new HttpRequestMessage(HttpMethod.Post, "v1/auth/login") { - var token = await _context.GetAuthToken(ct); - - if (!string.IsNullOrEmpty(token)) - { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + Content = GetJsonContent(loginRequest), + }; - if (response.StatusCode != HttpStatusCode.Unauthorized) - { - return response; - } + var loginResponse = await _httpClient.SendAsync(loginRequestMsg, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); - request.Headers.Authorization = null; - } - - var loginRequest = new TcLoginRequest(await _context.GetCredentials(ct)); - var loginRequestMsg = new HttpRequestMessage(HttpMethod.Post, "v1/auth/login") - { - Content = GetJsonContent(loginRequest) - }; - - var loginResponse = await _httpClient.SendAsync(loginRequestMsg, HttpCompletionOption.ResponseHeadersRead, ct); + if (!loginResponse.IsSuccessStatusCode) + { + throw new TcClientException(loginResponse.StatusCode, "Unable to login"); + } - if (!loginResponse.IsSuccessStatusCode) - { - throw new TcClientException(loginResponse.StatusCode, "Unable to login"); - } + var loginResponseStream = await loginResponse.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + await using var __ = loginResponseStream.ConfigureAwait(false); + var loginResponsePayload = await JsonSerializer.DeserializeAsync(loginResponseStream, TcJsonSerializerContext.Default.TcLoginResponse, ct).ConfigureAwait(false); - await using var loginResponseStream = await loginResponse.Content.ReadAsStreamAsync(); - var loginResponsePayload = await JsonSerializer.DeserializeAsync(loginResponseStream, _jsonOptions, ct); + if ((token = loginResponsePayload?.AccessToken) is null) + { + throw new TcClientException(loginResponse.StatusCode, "Invalid login response"); + } - if ((token = loginResponsePayload?.AccessToken) == null) - { - throw new TcClientException(loginResponse.StatusCode, "Invalid login response"); - } - else - { - await _context.SetAuthToken(ct, token); + await _context.SetAuthToken(ct, token).ConfigureAwait(false); - request.Headers.Authorization = new AuthenticationHeaderValue(loginResponsePayload?.TokenType ?? "Bearer", token); - return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); - } - } + request.Headers.Authorization = new AuthenticationHeaderValue(loginResponsePayload?.TokenType ?? "Bearer", token); + return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + } - private static HttpContent GetJsonContent(object entity) - { - var payload = JsonSerializer.Serialize(entity); - return new StringContent(payload, _encoding, "application/json"); - } + private static HttpContent GetJsonContent(TcLoginRequest entity) + { + var payload = JsonSerializer.Serialize(entity, TcJsonSerializerContext.Default.TcLoginRequest); + return new StringContent(payload, _encoding, "application/json"); + } - public void Dispose() - { - _httpClient.Dispose(); - } + public void Dispose() + { + _httpClient.Dispose(); } } diff --git a/src/Yllibed.TenantCloudClient/TcClientException.cs b/src/Yllibed.TenantCloudClient/TcClientException.cs index 121bb5f..3ccfc32 100644 --- a/src/Yllibed.TenantCloudClient/TcClientException.cs +++ b/src/Yllibed.TenantCloudClient/TcClientException.cs @@ -1,15 +1,13 @@ -using System; using System.Net; -namespace Yllibed.TenantCloudClient +namespace Yllibed.TenantCloudClient; + +public class TcClientException : Exception { - public class TcClientException : Exception - { - public HttpStatusCode HttpStatus { get; } + public HttpStatusCode HttpStatus { get; } - public TcClientException(HttpStatusCode httpStatus, string message, Exception? innerException = null) : base(message, innerException) - { - HttpStatus = httpStatus; - } + public TcClientException(HttpStatusCode httpStatus, string message, Exception? innerException = null) : base(message, innerException) + { + HttpStatus = httpStatus; } } diff --git a/src/Yllibed.TenantCloudClient/TcTenantsPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcTenantsPaginatedSourceExtensions.cs index 8849707..dc38149 100644 --- a/src/Yllibed.TenantCloudClient/TcTenantsPaginatedSourceExtensions.cs +++ b/src/Yllibed.TenantCloudClient/TcTenantsPaginatedSourceExtensions.cs @@ -1,38 +1,36 @@ -using System; using Yllibed.TenantCloudClient.HttpMessages; -namespace Yllibed.TenantCloudClient +namespace Yllibed.TenantCloudClient; + +public static class TcTenantsPaginatedSourceExtensions { - public static class TcTenantsPaginatedSourceExtensions + public static IPaginatedSource OnlyMovedIn(this IPaginatedSource source) { - public static IPaginatedSource OnlyMovedIn(this IPaginatedSource source) + if (source is PaginatedSource paginatedSource) { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=moved_in"); - } - - throw new ArgumentException("Invalid source."); + return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=moved_in"); } - public static IPaginatedSource OnlyArchived(this IPaginatedSource source) - { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=archived"); - } + throw new ArgumentException("Invalid source.", nameof(source)); + } - throw new ArgumentException("Invalid source."); + public static IPaginatedSource OnlyArchived(this IPaginatedSource source) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=archived"); } - public static IPaginatedSource OnlyNoLease(this IPaginatedSource source) - { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=no_lease"); - } + throw new ArgumentException("Invalid source.", nameof(source)); + } - throw new ArgumentException("Invalid source."); + public static IPaginatedSource OnlyNoLease(this IPaginatedSource source) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=no_lease"); } + + throw new ArgumentException("Invalid source.", nameof(source)); } } diff --git a/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs index 8a99aa6..7983030 100644 --- a/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs +++ b/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs @@ -1,82 +1,79 @@ -using System; using System.Globalization; using Yllibed.TenantCloudClient.HttpMessages; -namespace Yllibed.TenantCloudClient +namespace Yllibed.TenantCloudClient; + +public static class TcTransactionsPaginatedSourceExtensions { - public static class TcTransactionsPaginatedSourceExtensions + public static IPaginatedSource ForTenant(this IPaginatedSource source, long tenantId) { - public static IPaginatedSource ForTenant(this IPaginatedSource source, long tenantId) + if (source is PaginatedSource paginatedSource) { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&client=" + tenantId.ToString(NumberFormatInfo.InvariantInfo)); - } - - throw new ArgumentException("Invalid source."); + return paginatedSource.ProjectedWithExtraUrl(url => url + "&client=" + tenantId.ToString(NumberFormatInfo.InvariantInfo)); } - public static IPaginatedSource ForProperty(this IPaginatedSource source, long propertyId) - { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&property=" + propertyId.ToString(NumberFormatInfo.InvariantInfo)); - } + throw new ArgumentException("Invalid source.", nameof(source)); + } - throw new ArgumentException("Invalid source."); + public static IPaginatedSource ForProperty(this IPaginatedSource source, long propertyId) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&property=" + propertyId.ToString(NumberFormatInfo.InvariantInfo)); } - public static IPaginatedSource ForUnit(this IPaginatedSource source, long unitId) - { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&unit=" + unitId.ToString(NumberFormatInfo.InvariantInfo)); - } + throw new ArgumentException("Invalid source.", nameof(source)); + } - throw new ArgumentException("Invalid source."); + public static IPaginatedSource ForUnit(this IPaginatedSource source, long unitId) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&unit=" + unitId.ToString(NumberFormatInfo.InvariantInfo)); } - public static IPaginatedSource ForStatus(this IPaginatedSource source, TcTransactionStatus status) - { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&status=" + status.ToSerializedString()); - } + throw new ArgumentException("Invalid source.", nameof(source)); + } - throw new ArgumentException("Invalid source."); + public static IPaginatedSource ForStatus(this IPaginatedSource source, TcTransactionStatus status) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&status=" + status.ToSerializedString()); } - public static IPaginatedSource ForCategory(this IPaginatedSource source, TcTransactionCategory category) - { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&category=" + category.ToString().ToLowerInvariant()); - } + throw new ArgumentException("Invalid source.", nameof(source)); + } - throw new ArgumentException("Invalid source."); + public static IPaginatedSource ForCategory(this IPaginatedSource source, TcTransactionCategory category) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&category=" + category.ToString().ToLowerInvariant()); } - internal static string ToSerializedString(this TcTransactionStatus status) + throw new ArgumentException("Invalid source.", nameof(source)); + } + + internal static string ToSerializedString(this TcTransactionStatus status) + { + switch (status) { - switch(status) - { - case TcTransactionStatus.Due: - case TcTransactionStatus.Paid: - case TcTransactionStatus.Partial: - case TcTransactionStatus.Pending: - case TcTransactionStatus.Void: - var b = (byte) status; - return b.ToString(NumberFormatInfo.InvariantInfo); - case TcTransactionStatus.WithBalance: - return "with_balance"; - case TcTransactionStatus.Overdue: - return "overdue"; - case TcTransactionStatus.Waive: - return "waive"; - default: - throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown status"); - } + case TcTransactionStatus.Due: + case TcTransactionStatus.Paid: + case TcTransactionStatus.Partial: + case TcTransactionStatus.Pending: + case TcTransactionStatus.Void: + var b = (byte)status; + return b.ToString(NumberFormatInfo.InvariantInfo); + case TcTransactionStatus.WithBalance: + return "with_balance"; + case TcTransactionStatus.Overdue: + return "overdue"; + case TcTransactionStatus.Waive: + return "waive"; + default: + throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown status"); } - } } diff --git a/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs index b10ccdf..a58348c 100644 --- a/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs +++ b/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs @@ -1,39 +1,37 @@ -using System; using System.Globalization; using Yllibed.TenantCloudClient.HttpMessages; -namespace Yllibed.TenantCloudClient +namespace Yllibed.TenantCloudClient; + +public static class TcUnitsPaginatedSourceExtensions { - public static class TcUnitsPaginatedSourceExtensions + public static IPaginatedSource OnlyOccuped(this IPaginatedSource source) { - public static IPaginatedSource OnlyOccuped(this IPaginatedSource source) + if (source is PaginatedSource paginatedSource) { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=occuped"); - } - - throw new ArgumentException("Invalid source."); + return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=occuped"); } - public static IPaginatedSource OnlyVacant(this IPaginatedSource source) - { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=vacant"); - } + throw new ArgumentException("Invalid source.", nameof(source)); + } - throw new ArgumentException("Invalid source."); + public static IPaginatedSource OnlyVacant(this IPaginatedSource source) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=vacant"); } - public static IPaginatedSource ForProperty(this IPaginatedSource source, long propertyId) - { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&property=" + propertyId.ToString(NumberFormatInfo.InvariantInfo)); - } + throw new ArgumentException("Invalid source.", nameof(source)); + } - throw new ArgumentException("Invalid source."); + public static IPaginatedSource ForProperty(this IPaginatedSource source, long propertyId) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&property=" + propertyId.ToString(NumberFormatInfo.InvariantInfo)); } + + throw new ArgumentException("Invalid source.", nameof(source)); } } From 98b8007cc46c9ec8ac6862c9bad76ea106af2815 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 15:37:57 -0500 Subject: [PATCH 05/17] Refactored authentication mechanism to use `ITcAuthTokenProvider`, removed login request/response messages, and simplified token handling logic. --- .../HttpMessages/TcJsonSerializerContext.cs | 2 - .../HttpMessages/TcLoginRequest.cs | 22 ------- .../HttpMessages/TcLoginResponse.cs | 21 ------- .../ITcAuthTokenProvider.cs | 24 ++++++++ src/Yllibed.TenantCloudClient/TcClient.cs | 61 +++++++------------ 5 files changed, 45 insertions(+), 85 deletions(-) delete mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcLoginRequest.cs delete mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcLoginResponse.cs create mode 100644 src/Yllibed.TenantCloudClient/ITcAuthTokenProvider.cs diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs index 934cd9d..1026b80 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs @@ -11,8 +11,6 @@ namespace Yllibed.TenantCloudClient.HttpMessages; [JsonSerializable(typeof(TcListResponse))] [JsonSerializable(typeof(TcListResponse))] [JsonSerializable(typeof(TcErrorResponse))] -[JsonSerializable(typeof(TcLoginResponse))] -[JsonSerializable(typeof(TcLoginRequest))] internal partial class TcJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginRequest.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginRequest.cs deleted file mode 100644 index d3b7ab3..0000000 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginRequest.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Net; -using System.Text.Json.Serialization; - -namespace Yllibed.TenantCloudClient.HttpMessages; - -internal class TcLoginRequest -{ - [JsonPropertyName("email")] - public string? Email { get; set; } - - [JsonPropertyName("password")] - public string? Password { get; set; } - - [JsonPropertyName("persistent")] - public int IsPersistent { get; set; } = 1; - - public TcLoginRequest(NetworkCredential netCredentials) - { - Email = netCredentials.UserName; - Password = netCredentials.Password; - } -} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginResponse.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginResponse.cs deleted file mode 100644 index 3d4bb57..0000000 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginResponse.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Yllibed.TenantCloudClient.HttpMessages; - -public class TcLoginResponse -{ - [JsonPropertyName("token_type")] - public string? TokenType { get; set; } - - [JsonPropertyName("expires_in")] - public ulong? ExpiresIn { get; set; } - - [JsonPropertyName("access_token")] - public string? AccessToken { get; set; } - - [JsonPropertyName("refresh_token")] - public string? RefreshToken { get; set; } - - [JsonPropertyName("user_id")] - public long? UserId { get; set; } -} diff --git a/src/Yllibed.TenantCloudClient/ITcAuthTokenProvider.cs b/src/Yllibed.TenantCloudClient/ITcAuthTokenProvider.cs new file mode 100644 index 0000000..16bd716 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/ITcAuthTokenProvider.cs @@ -0,0 +1,24 @@ +namespace Yllibed.TenantCloudClient; + +/// +/// Provides Bearer tokens for TenantCloud API authentication. +/// +public interface ITcAuthTokenProvider +{ + /// + /// Returns a valid Bearer token, or null if no token is available. + /// + Task GetToken(CancellationToken ct); + + /// + /// Called when the server returns 401 Unauthorized. + /// The provider should invalidate its cached token so that the next + /// call attempts a refresh. + /// + /// Cancellation token. + /// + /// The token that was rejected, to avoid a concurrency bug where two + /// simultaneous requests both receive 401. + /// + Task OnTokenRejected(CancellationToken ct, string rejectedToken); +} diff --git a/src/Yllibed.TenantCloudClient/TcClient.cs b/src/Yllibed.TenantCloudClient/TcClient.cs index d18152e..9de5bfc 100644 --- a/src/Yllibed.TenantCloudClient/TcClient.cs +++ b/src/Yllibed.TenantCloudClient/TcClient.cs @@ -1,7 +1,6 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; -using System.Text; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Yllibed.TenantCloudClient.HttpMessages; @@ -10,14 +9,12 @@ namespace Yllibed.TenantCloudClient; public class TcClient : IDisposable, ITcClient { - private readonly ITcContext _context; + private readonly ITcAuthTokenProvider _tokenProvider; private readonly HttpClient _httpClient; - private static readonly Encoding _encoding = new UTF8Encoding(false); - - public TcClient(ITcContext context) + public TcClient(ITcAuthTokenProvider tokenProvider) { - _context = context; + _tokenProvider = tokenProvider; Tenants = new PaginatedSource(GetTenantPage, ""); @@ -110,53 +107,37 @@ private async Task HttpGet(CancellationToken ct, string uri, JsonTypeInfo< private async Task HttpSend(CancellationToken ct, HttpRequestMessage request) { - var token = await _context.GetAuthToken(ct).ConfigureAwait(false); + var token = await _tokenProvider.GetToken(ct).ConfigureAwait(false); - if (!string.IsNullOrEmpty(token)) + if (string.IsNullOrEmpty(token)) { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); - - if (response.StatusCode != HttpStatusCode.Unauthorized) - { - return response; - } - - request.Headers.Authorization = null; + throw new TcClientException(HttpStatusCode.Unauthorized, "No auth token available"); } - var loginRequest = new TcLoginRequest(await _context.GetCredentials(ct).ConfigureAwait(false)); - var loginRequestMsg = new HttpRequestMessage(HttpMethod.Post, "v1/auth/login") - { - Content = GetJsonContent(loginRequest), - }; - - var loginResponse = await _httpClient.SendAsync(loginRequestMsg, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); - if (!loginResponse.IsSuccessStatusCode) + if (response.StatusCode != HttpStatusCode.Unauthorized) { - throw new TcClientException(loginResponse.StatusCode, "Unable to login"); + return response; } - var loginResponseStream = await loginResponse.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); - await using var __ = loginResponseStream.ConfigureAwait(false); - var loginResponsePayload = await JsonSerializer.DeserializeAsync(loginResponseStream, TcJsonSerializerContext.Default.TcLoginResponse, ct).ConfigureAwait(false); + // Token was rejected — notify provider and try once more + response.Dispose(); + await _tokenProvider.OnTokenRejected(ct, token).ConfigureAwait(false); + + var newToken = await _tokenProvider.GetToken(ct).ConfigureAwait(false); - if ((token = loginResponsePayload?.AccessToken) is null) + if (string.IsNullOrEmpty(newToken) || string.Equals(newToken, token, StringComparison.Ordinal)) { - throw new TcClientException(loginResponse.StatusCode, "Invalid login response"); + throw new TcClientException(HttpStatusCode.Unauthorized, "Auth token rejected and no new token available"); } - await _context.SetAuthToken(ct, token).ConfigureAwait(false); + // HttpRequestMessage cannot be reused after SendAsync, so create a new one + var retryRequest = new HttpRequestMessage(request.Method, request.RequestUri); + retryRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", newToken); - request.Headers.Authorization = new AuthenticationHeaderValue(loginResponsePayload?.TokenType ?? "Bearer", token); - return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); - } - - private static HttpContent GetJsonContent(TcLoginRequest entity) - { - var payload = JsonSerializer.Serialize(entity, TcJsonSerializerContext.Default.TcLoginRequest); - return new StringContent(payload, _encoding, "application/json"); + return await _httpClient.SendAsync(retryRequest, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); } public void Dispose() From 8c708eb383c36fd2a29fadc7878ea3f01e025f4f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 15:38:10 -0500 Subject: [PATCH 06/17] Refactored token handling: replaced `InMemoryTcContext` with `StaticTokenProvider`, updated tests to use environment-based token provider. --- .../Given_TcClient.cs | 34 ++++-------- .../TestBase.cs | 18 ++++++- src/Yllibed.TenantCloudClient/ITcContext.cs | 36 ------------- .../InMemoryTcContext.cs | 53 ------------------- .../StaticTokenProvider.cs | 27 ++++++++++ 5 files changed, 54 insertions(+), 114 deletions(-) delete mode 100644 src/Yllibed.TenantCloudClient/ITcContext.cs delete mode 100644 src/Yllibed.TenantCloudClient/InMemoryTcContext.cs create mode 100644 src/Yllibed.TenantCloudClient/StaticTokenProvider.cs diff --git a/src/Yllibed.TenantCloudClient.Tests/Given_TcClient.cs b/src/Yllibed.TenantCloudClient.Tests/Given_TcClient.cs index 9ab4139..90f2029 100644 --- a/src/Yllibed.TenantCloudClient.Tests/Given_TcClient.cs +++ b/src/Yllibed.TenantCloudClient.Tests/Given_TcClient.cs @@ -6,15 +6,10 @@ namespace Yllibed.TenantCloudClient.Tests; [TestClass] public class Given_TcClient : TestBase { - private const string TcUsername = "landlord.test.tc@gmail.com"; - private const string TcPassword = "1234Zxcv"; - private readonly InMemoryTcContext _context = new(TcUsername, TcPassword); - [TestMethod] - [Ignore("Requires live TenantCloud API")] public async Task When_GettingUserInfo() { - var sut = new TcClient(_context); + var sut = new TcClient(TokenProvider); var userInfo = await sut.GetUserInfo(CancellationToken.None); userInfo.Should().NotBeNull(); @@ -24,10 +19,9 @@ public async Task When_GettingUserInfo() } [TestMethod] - [Ignore("Requires live TenantCloud API")] public async Task When_GettingAllTenants() { - var client = new TcClient(_context); + var client = new TcClient(TokenProvider); var sut = client.Tenants; var all = await sut.GetAll(CancellationToken.None); @@ -37,10 +31,9 @@ public async Task When_GettingAllTenants() } [TestMethod] - [Ignore("Requires live TenantCloud API")] public async Task When_GettingMovedInTenants() { - var client = new TcClient(_context); + var client = new TcClient(TokenProvider); var sut = client.Tenants.OnlyMovedIn(); var all = await sut.GetAll(CancellationToken.None); @@ -50,10 +43,9 @@ public async Task When_GettingMovedInTenants() } [TestMethod] - [Ignore("Requires live TenantCloud API")] public async Task When_GettingMNoLeaseTenants() { - var client = new TcClient(_context); + var client = new TcClient(TokenProvider); var sut = client.Tenants.OnlyNoLease(); var all = await sut.GetAll(CancellationToken.None); @@ -63,10 +55,9 @@ public async Task When_GettingMNoLeaseTenants() } [TestMethod] - [Ignore("Requires live TenantCloud API")] public async Task When_GetProperties() { - var client = new TcClient(_context); + var client = new TcClient(TokenProvider); var sut = client.Properties; var all = await sut.GetAll(CancellationToken.None); @@ -76,10 +67,9 @@ public async Task When_GetProperties() } [TestMethod] - [Ignore("Requires live TenantCloud API")] public async Task When_GetUnits() { - var client = new TcClient(_context); + var client = new TcClient(TokenProvider); var sut = client.Units; var all = await sut.GetAll(CancellationToken.None); @@ -89,10 +79,9 @@ public async Task When_GetUnits() } [TestMethod] - [Ignore("Requires live TenantCloud API")] public async Task When_GetTransactionsForTenant() { - var client = new TcClient(_context); + var client = new TcClient(TokenProvider); var firstTenantId = await GetFirstTenantId(client); var sut = client.Transactions @@ -105,10 +94,9 @@ public async Task When_GetTransactionsForTenant() } [TestMethod] - [Ignore("Requires live TenantCloud API")] public async Task When_GetTransactionsForUnit() { - var client = new TcClient(_context); + var client = new TcClient(TokenProvider); var firstUnitId = await GetFirstUnitId(client); var sut = client.Transactions @@ -120,10 +108,9 @@ public async Task When_GetTransactionsForUnit() } [TestMethod] - [Ignore("Requires live TenantCloud API")] public async Task When_GetExpenseTransactions() { - var client = new TcClient(_context); + var client = new TcClient(TokenProvider); var sut = client.Transactions .ForCategory(TcTransactionCategory.Expense); @@ -134,10 +121,9 @@ public async Task When_GetExpenseTransactions() } [TestMethod] - [Ignore("Requires live TenantCloud API")] public async Task When_GetBalancePerProperty() { - var client = new TcClient(_context); + var client = new TcClient(TokenProvider); var all = (await client.Transactions .ForCategory(TcTransactionCategory.Income) diff --git a/src/Yllibed.TenantCloudClient.Tests/TestBase.cs b/src/Yllibed.TenantCloudClient.Tests/TestBase.cs index eb9669a..04f06c5 100644 --- a/src/Yllibed.TenantCloudClient.Tests/TestBase.cs +++ b/src/Yllibed.TenantCloudClient.Tests/TestBase.cs @@ -2,7 +2,23 @@ namespace Yllibed.TenantCloudClient.Tests; public class TestBase { - internal TestBase() + private static readonly string? s_envToken = + Environment.GetEnvironmentVariable("TC_AUTH_TOKEN"); + + protected ITcAuthTokenProvider TokenProvider { get; private set; } = null!; + + [TestInitialize] + public void EnsureTokenAvailable() { + var token = s_envToken; + + if (string.IsNullOrEmpty(token)) + { + Assert.Inconclusive( + "No TenantCloud auth token available. " + + "Set TC_AUTH_TOKEN to run integration tests."); + } + + TokenProvider = new StaticTokenProvider(token); } } diff --git a/src/Yllibed.TenantCloudClient/ITcContext.cs b/src/Yllibed.TenantCloudClient/ITcContext.cs deleted file mode 100644 index fabf0f5..0000000 --- a/src/Yllibed.TenantCloudClient/ITcContext.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Net; - -namespace Yllibed.TenantCloudClient; - -/// -/// This is the interface the application must implement to use the API. -/// -public interface ITcContext -{ - /// - /// This method will be called by TcClient when it is required to - /// login again to update the auth token. - /// - /// - /// Properties UserName and Password are used for authentication. - /// - Task GetCredentials(CancellationToken ct); - - /// - /// This method will be called by TcClient to persist the auth token. - /// - /// - /// If you can, it should be persisted in a durable manner: like on disk. - /// You should treat this as the same security sensitivity as credentials. - /// - Task SetAuthToken(CancellationToken ct, string token); - - /// - /// This method will be called by TcClient to get the persisted token. - /// - /// - /// TcClient won't cache it and will call this method each time the token - /// is required. You should have a mechanism to serve it from cached memory. - /// - Task GetAuthToken(CancellationToken ct); -} diff --git a/src/Yllibed.TenantCloudClient/InMemoryTcContext.cs b/src/Yllibed.TenantCloudClient/InMemoryTcContext.cs deleted file mode 100644 index 60acd14..0000000 --- a/src/Yllibed.TenantCloudClient/InMemoryTcContext.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Net; - -namespace Yllibed.TenantCloudClient; - -/// -/// Naive TCContext implementation storing the token in memory. -/// -public class InMemoryTcContext : ITcContext -{ - private readonly Func> _credentials; - private string? _token; - - public InMemoryTcContext(Func> asyncCredentialsCallback) - { - _credentials = asyncCredentialsCallback; - } - - public InMemoryTcContext(Func> asyncCredentialsCallback) - { - _credentials = async ct => - { - var (username, password) = await asyncCredentialsCallback(ct).ConfigureAwait(false); - return new NetworkCredential(username, password); - }; - } - - public InMemoryTcContext(Func credentialsCallback) - { - _credentials = _ => Task.FromResult(credentialsCallback()); - } - - public InMemoryTcContext(NetworkCredential credentials) - { - _credentials = _ => Task.FromResult(credentials); - } - - public InMemoryTcContext(string username, string password) - { - var credentials = new NetworkCredential(username, password); - _credentials = _ => Task.FromResult(credentials); - } - - public Task GetCredentials(CancellationToken ct) => _credentials(ct); - - public Task SetAuthToken(CancellationToken ct, string token) - { - _token = token; - - return Task.CompletedTask; - } - - public Task GetAuthToken(CancellationToken ct) => Task.FromResult(_token); -} diff --git a/src/Yllibed.TenantCloudClient/StaticTokenProvider.cs b/src/Yllibed.TenantCloudClient/StaticTokenProvider.cs new file mode 100644 index 0000000..5a52465 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/StaticTokenProvider.cs @@ -0,0 +1,27 @@ +namespace Yllibed.TenantCloudClient; + +/// +/// A token provider that returns a fixed token. Once rejected, returns null. +/// +public class StaticTokenProvider : ITcAuthTokenProvider +{ + private string? _token; + + public StaticTokenProvider(string token) + { + _token = token ?? throw new ArgumentNullException(nameof(token)); + } + + public Task GetToken(CancellationToken ct) => Task.FromResult(_token); + + public Task OnTokenRejected(CancellationToken ct, string rejectedToken) + { + // Only invalidate if the rejected token matches the current one + if (string.Equals(_token, rejectedToken, StringComparison.Ordinal)) + { + _token = null; + } + + return Task.CompletedTask; + } +} From c471e8f6f70df2343caee04d280006380d115d03 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 16:35:30 -0500 Subject: [PATCH 07/17] Introduced `CdpTokenProvider` for Chrome DevTools Protocol-based token extraction and refresh, added supporting models and services for TenantCloud Client. --- src/TenantCloud.slnx | 1 + .../CdpBrowserDiscovery.cs | 98 ++++ .../CdpConnection.cs | 209 ++++++++ .../CdpJsonContext.cs | 20 + .../CdpMessages/CdpCookie.cs | 21 + .../CdpMessages/CdpError.cs | 12 + .../CdpMessages/CdpEvaluateParams.cs | 12 + .../CdpMessages/CdpEvaluateResult.cs | 12 + .../CdpMessages/CdpExceptionDetails.cs | 9 + .../CdpMessages/CdpGetCookiesParams.cs | 9 + .../CdpMessages/CdpGetCookiesResult.cs | 9 + .../CdpMessages/CdpRemoteObject.cs | 12 + .../CdpMessages/CdpRequest.cs | 17 + .../CdpMessages/CdpResponse.cs | 16 + .../CdpMessages/CdpTarget.cs | 21 + .../CdpMessages/TcRefreshRequest.cs | 15 + .../CdpMessages/TcRefreshResponse.cs | 21 + .../CdpTokenProvider.cs | 466 ++++++++++++++++++ .../CdpTokenProviderOptions.cs | 31 ++ .../ChromiumBrowser.cs | 3 + .../ChromiumFinder.cs | 298 +++++++++++ .../FileTokenStore.cs | 57 +++ .../ITcTokenStore.cs | 10 + .../JwtHelper.cs | 67 +++ .../TcTokenRefresher.cs | 58 +++ .../TcTokenSet.cs | 11 + .../Yllibed.TenantCloudClient.Cdp.csproj | 32 ++ 27 files changed, 1547 insertions(+) create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpBrowserDiscovery.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpConnection.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpJsonContext.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpCookie.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpError.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateParams.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateResult.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpExceptionDetails.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesParams.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesResult.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRemoteObject.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRequest.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpResponse.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpTarget.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshRequest.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshResponse.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpTokenProvider.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpTokenProviderOptions.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/ChromiumBrowser.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/ChromiumFinder.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/FileTokenStore.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/ITcTokenStore.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/JwtHelper.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/TcTokenRefresher.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/TcTokenSet.cs create mode 100644 src/Yllibed.TenantCloudClient.Cdp/Yllibed.TenantCloudClient.Cdp.csproj diff --git a/src/TenantCloud.slnx b/src/TenantCloud.slnx index e525542..3317364 100644 --- a/src/TenantCloud.slnx +++ b/src/TenantCloud.slnx @@ -1,4 +1,5 @@ + diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpBrowserDiscovery.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpBrowserDiscovery.cs new file mode 100644 index 0000000..ba5d6dd --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpBrowserDiscovery.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using Yllibed.TenantCloudClient.Cdp.CdpMessages; + +namespace Yllibed.TenantCloudClient.Cdp; + +internal static class CdpBrowserDiscovery +{ + public static async Task FindTenantCloudTargetAsync( + int port, + string appUrl, + TimeSpan timeout, + CancellationToken ct) + { + try + { + using var http = new HttpClient { Timeout = timeout }; + var json = await http.GetStringAsync($"http://localhost:{port}/json", ct).ConfigureAwait(false); + + var targets = JsonSerializer.Deserialize(json, CdpJsonContext.Default.CdpTargetArray); + if (targets is null) + { + return null; + } + + var appHost = new Uri(appUrl).Host; + + foreach (var target in targets) + { + if (!string.Equals(target.Type, "page", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.IsNullOrEmpty(target.Url) || string.IsNullOrEmpty(target.WebSocketDebuggerUrl)) + { + continue; + } + + if (Uri.TryCreate(target.Url, UriKind.Absolute, out var targetUri) + && targetUri.Host.EndsWith(appHost, StringComparison.OrdinalIgnoreCase)) + { + return new Uri(target.WebSocketDebuggerUrl); + } + } + + return null; + } + catch (Exception) when (ct.IsCancellationRequested) + { + return null; + } + catch (HttpRequestException) + { + return null; + } + catch (TaskCanceledException) + { + return null; + } + catch (JsonException) + { + return null; + } + } + + public static async Task FindAnyTargetAsync( + int port, + TimeSpan timeout, + CancellationToken ct) + { + try + { + using var http = new HttpClient { Timeout = timeout }; + var json = await http.GetStringAsync($"http://localhost:{port}/json", ct).ConfigureAwait(false); + + var targets = JsonSerializer.Deserialize(json, CdpJsonContext.Default.CdpTargetArray); + if (targets is null) + { + return null; + } + + foreach (var target in targets) + { + if (string.Equals(target.Type, "page", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(target.WebSocketDebuggerUrl)) + { + return new Uri(target.WebSocketDebuggerUrl); + } + } + + return null; + } + catch + { + return null; + } + } +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpConnection.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpConnection.cs new file mode 100644 index 0000000..4915331 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpConnection.cs @@ -0,0 +1,209 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Yllibed.TenantCloudClient.Cdp.CdpMessages; + +namespace Yllibed.TenantCloudClient.Cdp; + +internal sealed class CdpConnection : IAsyncDisposable +{ + private readonly ClientWebSocket _ws = new(); + private readonly ConcurrentDictionary> _pending = new(); + private int _nextId; + private CancellationTokenSource? _receiveCts; + private Task? _receiveLoop; + + public async Task ConnectAsync(Uri wsUrl, TimeSpan timeout, CancellationToken ct) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + + await _ws.ConnectAsync(wsUrl, cts.Token).ConfigureAwait(false); + + _receiveCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _receiveLoop = Task.Run(() => ReceiveLoopAsync(_receiveCts.Token), ct); + } + + public async Task SendCommandAsync( + string method, + JsonElement? @params, + JsonTypeInfo resultTypeInfo, + CancellationToken ct) + { + var result = await SendRawCommandAsync(method, @params, ct).ConfigureAwait(false); + + if (result is null) + { + return default; + } + + return result.Value.Deserialize(resultTypeInfo); + } + + private async Task SendRawCommandAsync( + string method, JsonElement? @params, CancellationToken ct) + { + var id = Interlocked.Increment(ref _nextId); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[id] = tcs; + + try + { + var request = new CdpRequest { Id = id, Method = method, Params = @params }; + var bytes = JsonSerializer.SerializeToUtf8Bytes(request, CdpJsonContext.Default.CdpRequest); + + await _ws.SendAsync(bytes, WebSocketMessageType.Text, true, ct).ConfigureAwait(false); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(30)); + + var registration = timeoutCts.Token.Register(() => + tcs.TrySetCanceled(timeoutCts.Token)); + + try + { + return await tcs.Task.ConfigureAwait(false); + } + finally + { + await registration.DisposeAsync().ConfigureAwait(false); + } + } + finally + { + _pending.TryRemove(id, out _); + } + } + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + var buffer = new byte[64 * 1024]; + + try + { + while (!ct.IsCancellationRequested && _ws.State == WebSocketState.Open) + { + using var ms = new MemoryStream(); + + var endOfMessage = await ReadFullMessageAsync(ms, buffer, ct).ConfigureAwait(false); + if (!endOfMessage) + { + return; // Close frame received + } + + if (ms.Length > 0) + { + DispatchResponse(ms.ToArray()); + } + } + } + catch (OperationCanceledException) + { + // Normal shutdown + } + catch (WebSocketException) + { + // Connection lost + } + finally + { + CancelAllPending(); + } + } + + private async Task ReadFullMessageAsync( + MemoryStream ms, byte[] buffer, CancellationToken ct) + { + WebSocketReceiveResult result; + do + { + result = await _ws.ReceiveAsync(buffer, ct).ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + return false; + } + + await ms.WriteAsync(buffer.AsMemory(0, result.Count), ct).ConfigureAwait(false); + } + while (!result.EndOfMessage); + + return true; + } + + private void DispatchResponse(byte[] data) + { + try + { + var response = JsonSerializer.Deserialize( + data.AsSpan(), + CdpJsonContext.Default.CdpResponse); + + if (response is not null && _pending.TryRemove(response.Id, out var tcs)) + { + if (response.Error is not null) + { + tcs.TrySetException(new InvalidOperationException( + $"CDP error {response.Error.Code}: {response.Error.Message}")); + } + else + { + tcs.TrySetResult(response.Result); + } + } + } + catch (JsonException) + { + // Ignore non-response messages (events, etc.) + } + } + + private void CancelAllPending() + { + foreach (var kvp in _pending) + { + if (_pending.TryRemove(kvp.Key, out var tcs)) + { + tcs.TrySetCanceled(); + } + } + } + + public async ValueTask DisposeAsync() + { + if (_receiveCts is not null) + { + await _receiveCts.CancelAsync().ConfigureAwait(false); + } + + if (_receiveLoop is not null) + { + try + { +#pragma warning disable VSTHRD003 // Awaiting our own background loop during disposal is safe + await _receiveLoop.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected + } + } + + if (_ws.State == WebSocketState.Open) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token).ConfigureAwait(false); + } + catch + { + // Best effort + } + } + + _ws.Dispose(); + _receiveCts?.Dispose(); + } +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpJsonContext.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpJsonContext.cs new file mode 100644 index 0000000..faacf92 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpJsonContext.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using Yllibed.TenantCloudClient.Cdp.CdpMessages; + +namespace Yllibed.TenantCloudClient.Cdp; + +[JsonSerializable(typeof(CdpTarget[]))] +[JsonSerializable(typeof(CdpRequest))] +[JsonSerializable(typeof(CdpResponse))] +[JsonSerializable(typeof(CdpCookie))] +[JsonSerializable(typeof(CdpGetCookiesResult))] +[JsonSerializable(typeof(CdpGetCookiesParams))] +[JsonSerializable(typeof(CdpEvaluateResult))] +[JsonSerializable(typeof(CdpEvaluateParams))] +[JsonSerializable(typeof(TcRefreshRequest))] +[JsonSerializable(typeof(TcRefreshResponse))] +[JsonSerializable(typeof(TcTokenSet))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +internal sealed partial class CdpJsonContext : JsonSerializerContext; diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpCookie.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpCookie.cs new file mode 100644 index 0000000..c311a84 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpCookie.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; + +internal sealed class CdpCookie +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("value")] + public string Value { get; set; } = ""; + + [JsonPropertyName("domain")] + public string Domain { get; set; } = ""; + + [JsonPropertyName("httpOnly")] + public bool HttpOnly { get; set; } + + [JsonPropertyName("secure")] + public bool Secure { get; set; } +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpError.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpError.cs new file mode 100644 index 0000000..0921129 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpError.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; + +internal sealed class CdpError +{ + [JsonPropertyName("code")] + public int Code { get; set; } + + [JsonPropertyName("message")] + public string Message { get; set; } = ""; +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateParams.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateParams.cs new file mode 100644 index 0000000..ee20291 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateParams.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; + +internal sealed class CdpEvaluateParams +{ + [JsonPropertyName("expression")] + public string Expression { get; set; } = ""; + + [JsonPropertyName("returnByValue")] + public bool ReturnByValue { get; set; } = true; +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateResult.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateResult.cs new file mode 100644 index 0000000..67d6e8f --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateResult.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; + +internal sealed class CdpEvaluateResult +{ + [JsonPropertyName("result")] + public CdpRemoteObject? Result { get; set; } + + [JsonPropertyName("exceptionDetails")] + public CdpExceptionDetails? ExceptionDetails { get; set; } +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpExceptionDetails.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpExceptionDetails.cs new file mode 100644 index 0000000..83c4687 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpExceptionDetails.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; + +internal sealed class CdpExceptionDetails +{ + [JsonPropertyName("text")] + public string Text { get; set; } = ""; +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesParams.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesParams.cs new file mode 100644 index 0000000..11ebb5d --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesParams.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; + +internal sealed class CdpGetCookiesParams +{ + [JsonPropertyName("urls")] + public string[] Urls { get; set; } = []; +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesResult.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesResult.cs new file mode 100644 index 0000000..d25eaad --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesResult.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; + +internal sealed class CdpGetCookiesResult +{ + [JsonPropertyName("cookies")] + public CdpCookie[] Cookies { get; set; } = []; +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRemoteObject.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRemoteObject.cs new file mode 100644 index 0000000..9ff1a2c --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRemoteObject.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; + +internal sealed class CdpRemoteObject +{ + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + [JsonPropertyName("value")] + public object? Value { get; set; } +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRequest.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRequest.cs new file mode 100644 index 0000000..fb93895 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRequest.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; + +internal sealed class CdpRequest +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("method")] + public string Method { get; set; } = ""; + + [JsonPropertyName("params")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Params { get; set; } +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpResponse.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpResponse.cs new file mode 100644 index 0000000..7b2e5dc --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; + +internal sealed class CdpResponse +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("result")] + public JsonElement? Result { get; set; } + + [JsonPropertyName("error")] + public CdpError? Error { get; set; } +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpTarget.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpTarget.cs new file mode 100644 index 0000000..9a2e8f2 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpTarget.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; + +internal sealed class CdpTarget +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + [JsonPropertyName("title")] + public string Title { get; set; } = ""; + + [JsonPropertyName("url")] + public string Url { get; set; } = ""; + + [JsonPropertyName("webSocketDebuggerUrl")] + public string WebSocketDebuggerUrl { get; set; } = ""; +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshRequest.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshRequest.cs new file mode 100644 index 0000000..9973e83 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshRequest.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; + +internal sealed class TcRefreshRequest +{ + [JsonPropertyName("grant_type")] + public string GrantType { get; set; } = "refresh_token"; + + [JsonPropertyName("fingerprint")] + public string Fingerprint { get; set; } = ""; + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } = ""; +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshResponse.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshResponse.cs new file mode 100644 index 0000000..d72acd2 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; + +internal sealed class TcRefreshResponse +{ + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + + [JsonPropertyName("token_type")] + public string? TokenType { get; set; } + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + [JsonPropertyName("user_id")] + public int UserId { get; set; } +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpTokenProvider.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpTokenProvider.cs new file mode 100644 index 0000000..51dae8f --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpTokenProvider.cs @@ -0,0 +1,466 @@ +using System.Diagnostics; +using System.Text.Json; +using Yllibed.TenantCloudClient.Cdp.CdpMessages; + +namespace Yllibed.TenantCloudClient.Cdp; + +/// +/// Token provider that connects to a Chromium browser via Chrome DevTools Protocol +/// to extract TenantCloud auth tokens from an existing browser session, +/// with automatic refresh and optional interactive login. +/// +public sealed class CdpTokenProvider : ITcAuthTokenProvider, IDisposable +{ + private readonly CdpTokenProviderOptions _options; + private readonly SemaphoreSlim _gate = new(1, 1); + + private TcTokenSet? _cached; + private Process? _browserProcess; + + public CdpTokenProvider(CdpTokenProviderOptions? options = null) + { + _options = options ?? new CdpTokenProviderOptions(); + } + + /// + public async Task GetToken(CancellationToken ct) + { + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + return await GetTokenCoreAsync(ct).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// + public async Task OnTokenRejected(CancellationToken ct, string rejectedToken) + { + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + if (_cached is not null + && string.Equals(_cached.AccessToken, rejectedToken, StringComparison.Ordinal)) + { + var refreshed = await TcTokenRefresher.RefreshAsync( + _cached, _options.TenantCloudApiUrl, ct).ConfigureAwait(false); + + if (refreshed is not null) + { + _cached = refreshed; + await PersistAsync(refreshed, ct).ConfigureAwait(false); + } + else + { + _cached = null; + } + } + } + catch + { + _cached = null; + } + finally + { + _gate.Release(); + } + } + + private async Task GetTokenCoreAsync(CancellationToken ct) + { + // Step 1: In-memory cache — return if not expired + if (_cached is not null && !IsExpiredOrExpiring(_cached.AccessToken)) + { + return _cached.AccessToken; + } + + // Step 2: Token store — load, then try refresh if expired + var tokens = await TryLoadAndRefreshFromStoreAsync(ct).ConfigureAwait(false); + if (tokens is not null) + { + _cached = tokens; + return tokens.AccessToken; + } + + // Step 3: CDP extraction from an existing browser + tokens = await TryExtractFromBrowserAsync(_options.DebugPort, ct).ConfigureAwait(false); + if (tokens is not null) + { + _cached = tokens; + await PersistAsync(tokens, ct).ConfigureAwait(false); + return tokens.AccessToken; + } + + // Step 4: Interactive login (if allowed) + if (_options.AllowInteractiveLogin) + { + tokens = await TryInteractiveLoginAsync(ct).ConfigureAwait(false); + if (tokens is not null) + { + _cached = tokens; + await PersistAsync(tokens, ct).ConfigureAwait(false); + return tokens.AccessToken; + } + } + + // Step 5: Nothing worked + return null; + } + + private async Task TryLoadAndRefreshFromStoreAsync(CancellationToken ct) + { + if (_options.TokenStore is null) + { + return null; + } + + try + { + var stored = await _options.TokenStore.LoadAsync(ct).ConfigureAwait(false); + if (stored is null) + { + return null; + } + + if (!IsExpiredOrExpiring(stored.AccessToken)) + { + return stored; + } + + var refreshed = await TcTokenRefresher.RefreshAsync( + stored, _options.TenantCloudApiUrl, ct).ConfigureAwait(false); + + if (refreshed is not null) + { + await PersistAsync(refreshed, ct).ConfigureAwait(false); + } + + return refreshed; + } + catch + { + return null; + } + } + + private async Task TryExtractFromBrowserAsync(int port, CancellationToken ct) + { + try + { + var wsUrl = await CdpBrowserDiscovery.FindTenantCloudTargetAsync( + port, _options.TenantCloudAppUrl, _options.DiscoveryTimeout, ct).ConfigureAwait(false); + + if (wsUrl is null) + { + return null; + } + + return await ExtractTokensViaCdpAsync(wsUrl, ct).ConfigureAwait(false); + } + catch + { + return null; + } + } + + private async Task ExtractTokensViaCdpAsync(Uri wsUrl, CancellationToken ct) + { + var connection = new CdpConnection(); + await using (connection.ConfigureAwait(false)) + { + await connection.ConnectAsync(wsUrl, _options.WebSocketTimeout, ct).ConfigureAwait(false); + + var accessToken = await EvaluateStringAsync( + connection, "JSON.parse(localStorage.getItem('access_token'))", ct).ConfigureAwait(false); + + var fingerprint = await EvaluateStringAsync( + connection, "JSON.parse(localStorage.getItem('fingerprint'))", ct).ConfigureAwait(false); + + var refreshToken = await ExtractRefreshTokenCookieAsync(connection, ct).ConfigureAwait(false); + + if (accessToken is null || fingerprint is null || refreshToken is null) + { + return null; + } + + return new TcTokenSet(accessToken, refreshToken, fingerprint); + } + } + + private static async Task EvaluateStringAsync( + CdpConnection connection, string expression, CancellationToken ct) + { + try + { + var evalParams = new CdpEvaluateParams { Expression = expression, ReturnByValue = true }; + var paramsElement = JsonSerializer.SerializeToElement( + evalParams, CdpJsonContext.Default.CdpEvaluateParams); + + var result = await connection.SendCommandAsync( + "Runtime.evaluate", + paramsElement, + CdpJsonContext.Default.CdpEvaluateResult, + ct).ConfigureAwait(false); + + if (result?.ExceptionDetails is not null || result?.Result is null) + { + return null; + } + + return result.Result.Value?.ToString(); + } + catch + { + return null; + } + } + + private async Task ExtractRefreshTokenCookieAsync( + CdpConnection connection, CancellationToken ct) + { + try + { + var cookieParams = new CdpGetCookiesParams + { + Urls = [_options.TenantCloudApiUrl, _options.TenantCloudAppUrl], + }; + var paramsElement = JsonSerializer.SerializeToElement( + cookieParams, CdpJsonContext.Default.CdpGetCookiesParams); + + var result = await connection.SendCommandAsync( + "Network.getCookies", + paramsElement, + CdpJsonContext.Default.CdpGetCookiesResult, + ct).ConfigureAwait(false); + + if (result?.Cookies is null) + { + return null; + } + + foreach (var cookie in result.Cookies) + { + if (string.Equals(cookie.Name, "tc_refresh_token", StringComparison.Ordinal) + && !string.IsNullOrEmpty(cookie.Value)) + { + return cookie.Value; + } + } + + return null; + } + catch + { + return null; + } + } + + private async Task TryInteractiveLoginAsync(CancellationToken ct) + { + try + { + var browserPath = ResolveBrowserPath(); + if (browserPath is null) + { + return null; + } + + var port = Random.Shared.Next(10000, 60000); + var tempProfile = Path.Combine(Path.GetTempPath(), $"tc-cdp-{port}"); + + try + { + return await LaunchBrowserAndExtractTokensAsync( + browserPath, port, tempProfile, ct).ConfigureAwait(false); + } + finally + { + KillBrowserProcess(); + TryDeleteDirectory(tempProfile); + } + } + catch + { + return null; + } + } + + private string? ResolveBrowserPath() + { + if (_options.BrowserExecutablePath is not null) + { + return _options.BrowserExecutablePath; + } + + var browsers = ChromiumFinder.FindAll(); + return browsers.Count > 0 ? browsers[0].ExecutablePath : null; + } + + private async Task LaunchBrowserAndExtractTokensAsync( + string browserPath, int port, string tempProfile, CancellationToken ct) + { + Directory.CreateDirectory(tempProfile); + + var loginUrl = $"{_options.TenantCloudAppUrl.TrimEnd('/')}/login"; + + _browserProcess = Process.Start(new ProcessStartInfo + { + FileName = browserPath, + ArgumentList = + { + $"--remote-debugging-port={port}", + $"--app={loginUrl}", + $"--user-data-dir={tempProfile}", + "--no-first-run", + "--disable-extensions", + }, + UseShellExecute = false, + }); + + if (_browserProcess is null || _browserProcess.HasExited) + { + return null; + } + + // Wait for the browser to start CDP + await Task.Delay(2000, ct).ConfigureAwait(false); + + return await PollForLoginCompletionAsync(port, ct).ConfigureAwait(false); + } + + private async Task PollForLoginCompletionAsync(int port, CancellationToken ct) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromMinutes(5)); + + while (!timeoutCts.Token.IsCancellationRequested) + { + await Task.Delay(1500, timeoutCts.Token).ConfigureAwait(false); + + try + { + var tokens = await TryExtractAfterLoginAsync(port, timeoutCts.Token) + .ConfigureAwait(false); + if (tokens is not null) + { + return tokens; + } + } + catch (OperationCanceledException) + { + return null; + } + catch + { + // CDP not ready yet, keep polling + } + } + + return null; + } + + private async Task TryExtractAfterLoginAsync(int port, CancellationToken ct) + { + var wsUrl = await CdpBrowserDiscovery.FindAnyTargetAsync( + port, _options.DiscoveryTimeout, ct).ConfigureAwait(false); + + if (wsUrl is null) + { + return null; + } + + var connection = new CdpConnection(); + await using (connection.ConfigureAwait(false)) + { + await connection.ConnectAsync(wsUrl, _options.WebSocketTimeout, ct).ConfigureAwait(false); + + var currentUrl = await EvaluateStringAsync( + connection, "window.location.href", ct).ConfigureAwait(false); + + if (currentUrl is null) + { + return null; + } + + // Still on login or 2FA page — keep waiting + if (currentUrl.Contains("/login", StringComparison.OrdinalIgnoreCase) + || currentUrl.Contains("/two_factor", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // User has navigated past login — extract tokens + return await ExtractTokensViaCdpAsync(wsUrl, ct).ConfigureAwait(false); + } + } + + private async Task PersistAsync(TcTokenSet tokens, CancellationToken ct) + { + if (_options.TokenStore is not null) + { + try + { + await _options.TokenStore.SaveAsync(tokens, ct).ConfigureAwait(false); + } + catch + { + // Persistence failure is non-fatal + } + } + } + + private static bool IsExpiredOrExpiring(string? accessToken) + { + var expiry = JwtHelper.GetExpiry(accessToken); + if (expiry is null) + { + return true; + } + + return expiry.Value < DateTimeOffset.UtcNow.AddSeconds(60); + } + + private void KillBrowserProcess() + { + try + { + if (_browserProcess is { HasExited: false }) + { + _browserProcess.Kill(entireProcessTree: true); + } + } + catch + { + // Best effort + } + finally + { + _browserProcess?.Dispose(); + _browserProcess = null; + } + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch + { + // Best effort — temp profile cleanup + } + } + + public void Dispose() + { + KillBrowserProcess(); + _gate.Dispose(); + } +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpTokenProviderOptions.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpTokenProviderOptions.cs new file mode 100644 index 0000000..91eae68 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpTokenProviderOptions.cs @@ -0,0 +1,31 @@ +namespace Yllibed.TenantCloudClient.Cdp; + +/// +/// Configuration for . +/// +public sealed class CdpTokenProviderOptions +{ + /// CDP debug port to connect to an existing browser. + public int DebugPort { get; init; } = 9222; + + /// When true, the provider may launch a browser for interactive login. + public bool AllowInteractiveLogin { get; init; } = false; + + /// TenantCloud web application URL. + public string TenantCloudAppUrl { get; init; } = "https://app.tenantcloud.com"; + + /// TenantCloud API URL. + public string TenantCloudApiUrl { get; init; } = "https://api.tenantcloud.com"; + + /// Optional token store for persisting tokens across sessions. + public ITcTokenStore? TokenStore { get; init; } + + /// Override automatic browser discovery with a specific executable path. + public string? BrowserExecutablePath { get; init; } + + /// Timeout for CDP WebSocket operations. + public TimeSpan WebSocketTimeout { get; init; } = TimeSpan.FromSeconds(10); + + /// Timeout for CDP HTTP discovery (/json endpoint). + public TimeSpan DiscoveryTimeout { get; init; } = TimeSpan.FromSeconds(5); +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/ChromiumBrowser.cs b/src/Yllibed.TenantCloudClient.Cdp/ChromiumBrowser.cs new file mode 100644 index 0000000..eee2419 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/ChromiumBrowser.cs @@ -0,0 +1,3 @@ +namespace Yllibed.TenantCloudClient.Cdp; + +internal sealed record ChromiumBrowser(string Name, string ExecutablePath); diff --git a/src/Yllibed.TenantCloudClient.Cdp/ChromiumFinder.cs b/src/Yllibed.TenantCloudClient.Cdp/ChromiumFinder.cs new file mode 100644 index 0000000..2825dfd --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/ChromiumFinder.cs @@ -0,0 +1,298 @@ +using System.Diagnostics; + +namespace Yllibed.TenantCloudClient.Cdp; + +internal static class ChromiumFinder +{ + public static IReadOnlyList FindAll() + { + if (OperatingSystem.IsWindows()) + { + return FindOnWindows(); + } + + if (OperatingSystem.IsMacOS()) + { + return FindOnMacOS(); + } + + if (OperatingSystem.IsLinux()) + { + return FindOnLinux(); + } + + return []; + } + + private static IReadOnlyList FindOnWindows() + { + var defaultBrowser = DetectWindowsDefaultBrowser(); + var candidates = GetWindowsCandidates(); + return BuildOrderedList(candidates, defaultBrowser); + } + + private static string? DetectWindowsDefaultBrowser() + { + try + { + var regOutput = RunProcess("reg", + @"query ""HKCU\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice"" /v ProgId"); + if (regOutput is null) + { + return null; + } + + foreach (var line in regOutput.Split('\n')) + { + if (line.Contains("ProgId", StringComparison.OrdinalIgnoreCase)) + { + var parts = line.Trim().Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 3) + { + return parts[^1].Trim(); + } + } + } + } + catch + { + // Registry query failed + } + + return null; + } + + private static (string Name, string ProgId, string[] Paths)[] GetWindowsCandidates() + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + return + [ + ("Edge", "MSEdgeHTM", [ + Path.Combine(programFilesX86, @"Microsoft\Edge\Application\msedge.exe"), + Path.Combine(programFiles, @"Microsoft\Edge\Application\msedge.exe"), + ]), + ("Chrome", "ChromeHTML", [ + Path.Combine(programFiles, @"Google\Chrome\Application\chrome.exe"), + Path.Combine(localAppData, @"Google\Chrome\Application\chrome.exe"), + ]), + ("Brave", "BraveHTML", [ + Path.Combine(programFiles, @"BraveSoftware\Brave-Browser\Application\brave.exe"), + Path.Combine(localAppData, @"BraveSoftware\Brave-Browser\Application\brave.exe"), + ]), + ("Vivaldi", "VivaldiHTM", [ + Path.Combine(localAppData, @"Vivaldi\Application\vivaldi.exe"), + ]), + ("Chromium", "ChromiumHTM", [ + Path.Combine(localAppData, @"Chromium\Application\chrome.exe"), + ]), + ]; + } + + private static IReadOnlyList BuildOrderedList( + (string Name, string ProgId, string[] Paths)[] candidates, + string? defaultProgId) + { + var results = new List(); + + // Default browser first + if (defaultProgId is not null) + { + foreach (var (name, progId, paths) in candidates) + { + if (defaultProgId.Contains(progId, StringComparison.OrdinalIgnoreCase)) + { + var exe = paths.FirstOrDefault(File.Exists); + if (exe is not null) + { + results.Add(new ChromiumBrowser(name, exe)); + } + + break; + } + } + } + + // Then all others found on disk + foreach (var (name, _, paths) in candidates) + { + if (results.Any(b => string.Equals(b.Name, name, StringComparison.Ordinal))) + { + continue; + } + + var exe = paths.FirstOrDefault(File.Exists); + if (exe is not null) + { + results.Add(new ChromiumBrowser(name, exe)); + } + } + + return results; + } + + private static IReadOnlyList FindOnMacOS() + { + var defaultBundleId = DetectMacOSDefaultBrowser(); + var candidates = GetMacOSCandidates(); + + var results = new List(); + + // Default browser first + if (defaultBundleId is not null) + { + foreach (var (name, bundleId, path) in candidates) + { + if (string.Equals(defaultBundleId, bundleId, StringComparison.OrdinalIgnoreCase) + && File.Exists(path)) + { + results.Add(new ChromiumBrowser(name, path)); + break; + } + } + } + + // Then all others + foreach (var (name, _, path) in candidates) + { + if (results.Any(b => string.Equals(b.Name, name, StringComparison.Ordinal))) + { + continue; + } + + if (File.Exists(path)) + { + results.Add(new ChromiumBrowser(name, path)); + } + } + + return results; + } + + private static string? DetectMacOSDefaultBrowser() + { + try + { + var plistOutput = RunProcess("plutil", + "-extract LSHandlers json -o - ~/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist"); + + if (plistOutput is null) + { + return null; + } + + var idx = plistOutput.IndexOf("\"LSHandlerURLScheme\":\"https\"", StringComparison.OrdinalIgnoreCase); + if (idx < 0) + { + idx = plistOutput.IndexOf("\"LSHandlerURLScheme\" : \"https\"", StringComparison.OrdinalIgnoreCase); + } + + if (idx < 0) + { + return null; + } + + var regionStart = Math.Max(0, idx - 200); + var regionLength = Math.Min(400, plistOutput.Length - regionStart); + var region = plistOutput.Substring(regionStart, regionLength); + var roleIdx = region.IndexOf("LSHandlerRoleAll", StringComparison.OrdinalIgnoreCase); + + if (roleIdx < 0) + { + return null; + } + + var afterKey = region.Substring(roleIdx); + var colonIdx = afterKey.IndexOf(':'); + if (colonIdx < 0) + { + return null; + } + + var quoteStart = afterKey.IndexOf('"', colonIdx); + if (quoteStart < 0) + { + return null; + } + + var quoteEnd = afterKey.IndexOf('"', quoteStart + 1); + if (quoteEnd <= quoteStart) + { + return null; + } + + return afterKey.Substring(quoteStart + 1, quoteEnd - quoteStart - 1); + } + catch + { + return null; + } + } + + private static (string Name, string BundleId, string Path)[] GetMacOSCandidates() + { + return + [ + ("Chrome", "com.google.chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + ("Edge", "com.microsoft.edgemac", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"), + ("Brave", "com.brave.browser", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"), + ("Vivaldi", "com.vivaldi.vivaldi", "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi"), + ("Chromium", "org.chromium.chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"), + ]; + } + + private static IReadOnlyList FindOnLinux() + { + var results = new List(); + + var candidates = new (string Name, string Command)[] + { + ("Chrome", "google-chrome"), + ("Chromium", "chromium-browser"), + ("Edge", "microsoft-edge-stable"), + ("Brave", "brave-browser"), + }; + + foreach (var (name, command) in candidates) + { + var path = RunProcess("which", command)?.Trim(); + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + { + results.Add(new ChromiumBrowser(name, path)); + } + } + + return results; + } + + private static string? RunProcess(string fileName, string arguments) + { + try + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }, + }; + + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(5000); + + return process.ExitCode == 0 ? output : null; + } + catch + { + return null; + } + } +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/FileTokenStore.cs b/src/Yllibed.TenantCloudClient.Cdp/FileTokenStore.cs new file mode 100644 index 0000000..008ec16 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/FileTokenStore.cs @@ -0,0 +1,57 @@ +using System.Text.Json; + +namespace Yllibed.TenantCloudClient.Cdp; + +/// +/// Reads and writes to a JSON file with atomic writes (temp + rename). +/// +public sealed class FileTokenStore : ITcTokenStore +{ + private readonly string _filePath; + + public FileTokenStore(string filePath) + { + _filePath = filePath ?? throw new ArgumentNullException(nameof(filePath)); + } + + public async Task LoadAsync(CancellationToken ct) + { + if (!File.Exists(_filePath)) + { + return null; + } + + try + { + var bytes = await File.ReadAllBytesAsync(_filePath, ct).ConfigureAwait(false); + return JsonSerializer.Deserialize(bytes, CdpJsonContext.Default.TcTokenSet); + } + catch + { + return null; + } + } + + public async Task SaveAsync(TcTokenSet tokens, CancellationToken ct) + { + var dir = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + var tempPath = _filePath + ".tmp"; + try + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(tokens, CdpJsonContext.Default.TcTokenSet); + await File.WriteAllBytesAsync(tempPath, bytes, ct).ConfigureAwait(false); + File.Move(tempPath, _filePath, overwrite: true); + } + catch + { + // Clean up temp file on failure + try { File.Delete(tempPath); } catch { /* best effort */ } + throw; + } + } +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/ITcTokenStore.cs b/src/Yllibed.TenantCloudClient.Cdp/ITcTokenStore.cs new file mode 100644 index 0000000..52a70ec --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/ITcTokenStore.cs @@ -0,0 +1,10 @@ +namespace Yllibed.TenantCloudClient.Cdp; + +/// +/// Persists TenantCloud auth tokens across sessions. +/// +public interface ITcTokenStore +{ + Task LoadAsync(CancellationToken ct); + Task SaveAsync(TcTokenSet tokens, CancellationToken ct); +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/JwtHelper.cs b/src/Yllibed.TenantCloudClient.Cdp/JwtHelper.cs new file mode 100644 index 0000000..3ee46d3 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/JwtHelper.cs @@ -0,0 +1,67 @@ +using System.Text.Json; + +namespace Yllibed.TenantCloudClient.Cdp; + +internal static class JwtHelper +{ + /// + /// Decodes the JWT payload (without signature verification) and reads the "exp" claim. + /// Returns null if the token is malformed. + /// + public static DateTimeOffset? GetExpiry(string? token) + { + if (string.IsNullOrEmpty(token)) + { + return null; + } + + try + { + var parts = token.Split('.'); + if (parts.Length < 2) + { + return null; + } + + var payload = Base64UrlDecode(parts[1]); + if (payload is null) + { + return null; + } + + using var doc = JsonDocument.Parse(payload); + if (doc.RootElement.TryGetProperty("exp", out var expElement)) + { + var exp = expElement.GetDouble(); + return DateTimeOffset.UnixEpoch.AddSeconds(exp); + } + + return null; + } + catch + { + return null; + } + } + + private static byte[]? Base64UrlDecode(string input) + { + try + { + var s = input.Replace('-', '+').Replace('_', '/'); + switch (s.Length % 4) + { + case 2: s += "=="; break; + case 3: s += "="; break; + case 0: break; + default: return null; + } + + return Convert.FromBase64String(s); + } + catch + { + return null; + } + } +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/TcTokenRefresher.cs b/src/Yllibed.TenantCloudClient.Cdp/TcTokenRefresher.cs new file mode 100644 index 0000000..49a7600 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/TcTokenRefresher.cs @@ -0,0 +1,58 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Yllibed.TenantCloudClient.Cdp.CdpMessages; + +namespace Yllibed.TenantCloudClient.Cdp; + +internal static class TcTokenRefresher +{ + public static async Task RefreshAsync( + TcTokenSet current, + string apiUrl, + CancellationToken ct) + { + try + { + using var http = new HttpClient(); + var requestBody = new TcRefreshRequest + { + GrantType = "refresh_token", + Fingerprint = current.Fingerprint, + RefreshToken = current.RefreshToken, + }; + + var json = JsonSerializer.Serialize(requestBody, CdpJsonContext.Default.TcRefreshRequest); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + + using var request = new HttpRequestMessage(HttpMethod.Post, $"{apiUrl.TrimEnd('/')}/auth/token") + { + Content = content, + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", current.AccessToken); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Add("X-Requested-With", "XMLHttpRequest"); + + using var response = await http.SendAsync(request, ct).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + var responseJson = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + var result = JsonSerializer.Deserialize(responseJson, CdpJsonContext.Default.TcRefreshResponse); + + if (result?.AccessToken is null || result.RefreshToken is null) + { + return null; + } + + return new TcTokenSet(result.AccessToken, result.RefreshToken, current.Fingerprint); + } + catch (Exception) when (!ct.IsCancellationRequested) + { + return null; + } + } +} diff --git a/src/Yllibed.TenantCloudClient.Cdp/TcTokenSet.cs b/src/Yllibed.TenantCloudClient.Cdp/TcTokenSet.cs new file mode 100644 index 0000000..859bfc8 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/TcTokenSet.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.Cdp; + +/// +/// Holds the three pieces required for TenantCloud API authentication and token refresh. +/// +public sealed record TcTokenSet( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("refresh_token")] string RefreshToken, + [property: JsonPropertyName("fingerprint")] string Fingerprint); diff --git a/src/Yllibed.TenantCloudClient.Cdp/Yllibed.TenantCloudClient.Cdp.csproj b/src/Yllibed.TenantCloudClient.Cdp/Yllibed.TenantCloudClient.Cdp.csproj new file mode 100644 index 0000000..2870991 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/Yllibed.TenantCloudClient.Cdp.csproj @@ -0,0 +1,32 @@ + + + + net8.0;net10.0 + + CS1591 + + + + true + Yllibed.TenantCloudClient.Cdp + Carl de Billy + Yllibed project + Tenant Cloud API Client - CDP Token Provider + + Chrome DevTools Protocol (CDP) based token provider for + Yllibed.TenantCloudClient. Extracts auth tokens from + an existing browser session — no external NuGet dependencies. + + Copyright (C) 2019 - Carl de Billy - All Rights Reserved + https://github.com/yllibed/TenantCloudClient + https://github.com/yllibed/TenantCloudClient + git + MIT + tenantcloud;cdp + + + + + + + From f24f2dfcc90a157ea69f20a6b8b69e3dd0767315 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 17:20:26 -0500 Subject: [PATCH 08/17] Migrated list response models to unified JSON: replaced `TcListResponse` and `TcPagingListResponse` with `TcJsonApiResponse`, added `IHasId` for common ID handling, and refactored related classes. --- .../HttpMessages/IHasId.cs | 6 ++++++ .../{TcTenantDetails.cs => TcContact.cs} | 5 ++--- .../HttpMessages/TcJsonApiItem.cs | 16 ++++++++++++++++ .../HttpMessages/TcJsonApiMeta.cs | 9 +++++++++ ...etaPagination.cs => TcJsonApiPagination.cs} | 14 +++++++------- .../HttpMessages/TcJsonApiResponse.cs | 12 ++++++++++++ .../HttpMessages/TcJsonSerializerContext.cs | 9 +++++---- .../HttpMessages/TcLease.cs | 4 ++-- .../HttpMessages/TcListResponse.cs | 16 ---------------- .../HttpMessages/TcListResponsePagination.cs | 18 ------------------ .../HttpMessages/TcPagingListMeta.cs | 12 ------------ .../HttpMessages/TcPagingListResponse.cs | 16 ---------------- .../HttpMessages/TcPropertyAttributes.cs | 15 --------------- .../HttpMessages/TcTransaction.cs | 5 ++--- .../HttpMessages/TcUnit.cs | 5 ++--- 15 files changed, 63 insertions(+), 99 deletions(-) create mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/IHasId.cs rename src/Yllibed.TenantCloudClient/HttpMessages/{TcTenantDetails.cs => TcContact.cs} (96%) create mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiItem.cs create mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiMeta.cs rename src/Yllibed.TenantCloudClient/HttpMessages/{TcPagingListMetaPagination.cs => TcJsonApiPagination.cs} (84%) create mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiResponse.cs delete mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcListResponse.cs delete mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcListResponsePagination.cs delete mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMeta.cs delete mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListResponse.cs delete mode 100644 src/Yllibed.TenantCloudClient/HttpMessages/TcPropertyAttributes.cs diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/IHasId.cs b/src/Yllibed.TenantCloudClient/HttpMessages/IHasId.cs new file mode 100644 index 0000000..8292064 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/IHasId.cs @@ -0,0 +1,6 @@ +namespace Yllibed.TenantCloudClient.HttpMessages; + +internal interface IHasId +{ + long Id { get; set; } +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcTenantDetails.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs similarity index 96% rename from src/Yllibed.TenantCloudClient/HttpMessages/TcTenantDetails.cs rename to src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs index 5499895..52b7c5c 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcTenantDetails.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs @@ -3,10 +3,9 @@ namespace Yllibed.TenantCloudClient.HttpMessages; -public partial class TcTenantDetails +public partial class TcContact : IHasId { - [JsonPropertyName("id")] - [JsonConverter(typeof(JsonAutoLongConverter))] + [JsonIgnore] public long Id { get; set; } [JsonPropertyName("email")] diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiItem.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiItem.cs new file mode 100644 index 0000000..31c1acd --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiItem.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.HttpMessages; + +internal class TcJsonApiItem +{ + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("id")] + [JsonConverter(typeof(JsonAutoLongConverter))] + public long Id { get; set; } + + [JsonPropertyName("attributes")] + public T? Attributes { get; set; } +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiMeta.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiMeta.cs new file mode 100644 index 0000000..cd5ac70 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiMeta.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.HttpMessages; + +internal class TcJsonApiMeta +{ + [JsonPropertyName("pagination")] + public TcJsonApiPagination? Pagination { get; set; } +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMetaPagination.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiPagination.cs similarity index 84% rename from src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMetaPagination.cs rename to src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiPagination.cs index db532aa..f583221 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMetaPagination.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiPagination.cs @@ -2,19 +2,19 @@ namespace Yllibed.TenantCloudClient.HttpMessages; -internal class TcPagingListMetaPagination +internal class TcJsonApiPagination { + [JsonPropertyName("total")] + public long Total { get; set; } + [JsonPropertyName("count")] public long Count { get; set; } - [JsonPropertyName("current_page")] - public long CurrentPage { get; set; } - - [JsonPropertyName("par_page")] + [JsonPropertyName("per_page")] public long PerPage { get; set; } - [JsonPropertyName("total")] - public long Total { get; set; } + [JsonPropertyName("current_page")] + public long CurrentPage { get; set; } [JsonPropertyName("total_pages")] public long TotalPages { get; set; } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiResponse.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiResponse.cs new file mode 100644 index 0000000..651f6fe --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Yllibed.TenantCloudClient.HttpMessages; + +internal class TcJsonApiResponse +{ + [JsonPropertyName("data")] + public TcJsonApiItem[]? Data { get; set; } + + [JsonPropertyName("meta")] + public TcJsonApiMeta? Meta { get; set; } +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs index 1026b80..26aac58 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs @@ -6,10 +6,11 @@ namespace Yllibed.TenantCloudClient.HttpMessages; AllowTrailingCommas = true, PropertyNameCaseInsensitive = true)] [JsonSerializable(typeof(TcUserInfoResponse))] -[JsonSerializable(typeof(TcListResponse))] -[JsonSerializable(typeof(TcPagingListResponse))] -[JsonSerializable(typeof(TcListResponse))] -[JsonSerializable(typeof(TcListResponse))] +[JsonSerializable(typeof(TcJsonApiResponse))] +[JsonSerializable(typeof(TcJsonApiResponse))] +[JsonSerializable(typeof(TcJsonApiResponse))] +[JsonSerializable(typeof(TcJsonApiResponse))] +[JsonSerializable(typeof(TcJsonApiResponse))] [JsonSerializable(typeof(TcErrorResponse))] internal partial class TcJsonSerializerContext : JsonSerializerContext { diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs index e294fd8..547a5aa 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs @@ -2,9 +2,9 @@ namespace Yllibed.TenantCloudClient.HttpMessages; -public class TcLease +public class TcLease : IHasId { - [JsonPropertyName("id")] + [JsonIgnore] public long Id { get; set; } [JsonPropertyName("name")] diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponse.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponse.cs deleted file mode 100644 index 65b05af..0000000 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Yllibed.TenantCloudClient.HttpMessages; - -/// -/// Response used by /v1 api -/// -/// The type of entries in the list. -public class TcListResponse -{ - [JsonPropertyName("list")] - public T[]? Entries { get; set; } - - [JsonPropertyName("pagination")] - public TcListResponsePagination? Pagination { get; set; } -} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponsePagination.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponsePagination.cs deleted file mode 100644 index c8f4651..0000000 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponsePagination.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Yllibed.TenantCloudClient.HttpMessages; - -public class TcListResponsePagination -{ - [JsonPropertyName("current_page")] - public long CurrentPage { get; set; } - - [JsonPropertyName("last_page")] - public long LastPage { get; set; } - - [JsonPropertyName("per_page")] - public long PerPage { get; set; } - - [JsonPropertyName("total")] - public long Total { get; set; } -} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMeta.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMeta.cs deleted file mode 100644 index 3e4dd17..0000000 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListMeta.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Yllibed.TenantCloudClient.HttpMessages; - -internal class TcPagingListMeta -{ - [JsonPropertyName("pagination")] - public TcPagingListMetaPagination? Pagination { get; set; } - - [JsonPropertyName("units_count")] - public long UnitsCount { get; set; } -} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListResponse.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListResponse.cs deleted file mode 100644 index 2f4b369..0000000 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Yllibed.TenantCloudClient.HttpMessages; - -/// -/// Response used by /v2 api -/// -/// The type of entries in the list. -internal class TcPagingListResponse -{ - [JsonPropertyName("data")] - public T[]? Entries { get; set; } - - [JsonPropertyName("meta")] - public TcPagingListMeta? Meta { get; set; } -} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcPropertyAttributes.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcPropertyAttributes.cs deleted file mode 100644 index e49f695..0000000 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcPropertyAttributes.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Yllibed.TenantCloudClient.HttpMessages; - -public class TcPropertyAttributes -{ - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [JsonPropertyName("address1")] - public string Address1 { get; set; } = string.Empty; - - [JsonPropertyName("cityAddress")] - public string CityAddress { get; set; } = string.Empty; -} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs index 5aabf85..de2344a 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs @@ -2,10 +2,9 @@ namespace Yllibed.TenantCloudClient.HttpMessages; -public class TcTransaction +public class TcTransaction : IHasId { - [JsonPropertyName("id")] - [JsonConverter(typeof(JsonAutoLongConverter))] + [JsonIgnore] public long Id { get; set; } [JsonPropertyName("unit_id")] diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs index a4eff4f..7a540d7 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs @@ -2,10 +2,9 @@ namespace Yllibed.TenantCloudClient.HttpMessages; -public class TcUnit +public class TcUnit : IHasId { - [JsonPropertyName("id")] - [JsonConverter(typeof(JsonAutoLongConverter))] + [JsonIgnore] public long Id { get; set; } [JsonPropertyName("property_id")] From f4e079dceccc14983246acf34ea182a3ab2de0ee Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 17:20:33 -0500 Subject: [PATCH 09/17] Refactored API endpoints to use `GetJsonApiPage`, added support for `Contacts` and `Leases`, updated `TcProperty` model with unified attributes. --- .../HttpMessages/TcProperty.cs | 20 +++-- src/Yllibed.TenantCloudClient/ITcClient.cs | 4 +- src/Yllibed.TenantCloudClient/TcClient.cs | 76 +++++++++++-------- 3 files changed, 60 insertions(+), 40 deletions(-) diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs index a7e50ea..bed1417 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs @@ -3,16 +3,22 @@ namespace Yllibed.TenantCloudClient.HttpMessages; -public class TcProperty +public class TcProperty : IHasId { - [JsonPropertyName("id")] - [JsonConverter(typeof(JsonAutoLongConverter))] + [JsonIgnore] public long Id { get; set; } - public string? Name => Attributes?.Name; + [JsonPropertyName("name")] + public string? Name { get; set; } - public string Address => string.Format(CultureInfo.InvariantCulture, "{0} {1}", Attributes?.Address1, Attributes?.CityAddress); + [JsonPropertyName("address1")] + public string? Address1 { get; set; } - [JsonPropertyName("attributes")] - public TcPropertyAttributes? Attributes { get; set; } + [JsonPropertyName("cityAddress")] + public string? CityAddress { get; set; } + + [JsonPropertyName("property_status")] + public string? Status { get; set; } + + public string Address => string.Format(CultureInfo.InvariantCulture, "{0} {1}", Address1, CityAddress); } diff --git a/src/Yllibed.TenantCloudClient/ITcClient.cs b/src/Yllibed.TenantCloudClient/ITcClient.cs index 82505ec..a272b58 100644 --- a/src/Yllibed.TenantCloudClient/ITcClient.cs +++ b/src/Yllibed.TenantCloudClient/ITcClient.cs @@ -12,11 +12,13 @@ public interface ITcClient /// Task GetUserInfo(CancellationToken ct); - IPaginatedSource Tenants { get; } + IPaginatedSource Contacts { get; } IPaginatedSource Properties { get; } IPaginatedSource Units { get; } IPaginatedSource Transactions { get; } + + IPaginatedSource Leases { get; } } diff --git a/src/Yllibed.TenantCloudClient/TcClient.cs b/src/Yllibed.TenantCloudClient/TcClient.cs index 9de5bfc..e46ad81 100644 --- a/src/Yllibed.TenantCloudClient/TcClient.cs +++ b/src/Yllibed.TenantCloudClient/TcClient.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -16,13 +17,25 @@ public TcClient(ITcAuthTokenProvider tokenProvider) { _tokenProvider = tokenProvider; - Tenants = new PaginatedSource(GetTenantPage, ""); + Contacts = new PaginatedSource( + (ct, page, extra) => GetJsonApiPage(ct, "contacts", page, extra, + TcJsonSerializerContext.Default.TcJsonApiResponseTcContact), ""); - Properties = new PaginatedSource(GetPropertyPage, ""); + Properties = new PaginatedSource( + (ct, page, extra) => GetJsonApiPage(ct, "properties", page, extra, + TcJsonSerializerContext.Default.TcJsonApiResponseTcProperty), ""); - Units = new PaginatedSource(GetUnitsPage, ""); + Units = new PaginatedSource( + (ct, page, extra) => GetJsonApiPage(ct, "units", page, extra, + TcJsonSerializerContext.Default.TcJsonApiResponseTcUnit), ""); - Transactions = new PaginatedSource(GetTransactionsPage, ""); + Transactions = new PaginatedSource( + (ct, page, extra) => GetJsonApiPage(ct, "transactions", page, extra, + TcJsonSerializerContext.Default.TcJsonApiResponseTcTransaction), ""); + + Leases = new PaginatedSource( + (ct, page, extra) => GetJsonApiPage(ct, "leases", page, extra, + TcJsonSerializerContext.Default.TcJsonApiResponseTcLease), ""); var httpHandler = new HttpClientHandler() { @@ -34,7 +47,7 @@ public TcClient(ITcAuthTokenProvider tokenProvider) _httpClient = new HttpClient(httpHandler, true) { - BaseAddress = new Uri("https://home.tenantcloud.com/"), + BaseAddress = new Uri("https://api.tenantcloud.com/"), }; _httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Yllibed.TenantCloudClient", "0.1")); @@ -46,44 +59,43 @@ public TcClient(ITcAuthTokenProvider tokenProvider) public async Task GetUserInfo(CancellationToken ct) { - var result = await HttpGet(ct, "v1/auth/user", TcJsonSerializerContext.Default.TcUserInfoResponse).ConfigureAwait(false); + var result = await HttpGet(ct, "auth/user", TcJsonSerializerContext.Default.TcUserInfoResponse).ConfigureAwait(false); return result?.User; } - public IPaginatedSource Tenants { get; } - - private async Task<(ReadOnlyMemory, long, long)> GetTenantPage(CancellationToken ct, long pageNo, string extraUrl) - { - var response = await HttpGet(ct, "v1/landlord/tenants?page=" + pageNo.ToString(System.Globalization.CultureInfo.InvariantCulture) + extraUrl, TcJsonSerializerContext.Default.TcListResponseTcTenantDetails).ConfigureAwait(false); - var memory = new Memory(response.Entries); - return (memory, pageNo, response?.Pagination?.Total ?? 0); - } + public IPaginatedSource Contacts { get; } public IPaginatedSource Properties { get; } - private async Task<(ReadOnlyMemory, long, long)> GetPropertyPage(CancellationToken ct, long pageNo, string extraUrl) - { - var response = await HttpGet(ct, "v2/property?fields[property]=name,property_status,address1,cityAddress&page=" + pageNo.ToString(System.Globalization.CultureInfo.InvariantCulture) + extraUrl, TcJsonSerializerContext.Default.TcPagingListResponseTcProperty).ConfigureAwait(false); - var memory = new Memory(response.Entries); - return (memory, pageNo, response?.Meta?.Pagination?.Total ?? 0); - } - public IPaginatedSource Units { get; } - private async Task<(ReadOnlyMemory, long, long)> GetUnitsPage(CancellationToken ct, long pageNo, string extraUrl) - { - var response = await HttpGet(ct, "v1/landlord/units?page=" + pageNo.ToString(System.Globalization.CultureInfo.InvariantCulture) + extraUrl, TcJsonSerializerContext.Default.TcListResponseTcUnit).ConfigureAwait(false); - var memory = new Memory(response.Entries); - return (memory, pageNo, response?.Pagination?.Total ?? 0); - } - public IPaginatedSource Transactions { get; } - private async Task<(ReadOnlyMemory, long, long)> GetTransactionsPage(CancellationToken ct, long pageNo, string extraUrl) + public IPaginatedSource Leases { get; } + + private async Task<(ReadOnlyMemory, long, long)> GetJsonApiPage( + CancellationToken ct, string endpoint, long pageNo, string extraUrl, + JsonTypeInfo> typeInfo) + where T : class, IHasId { - var response = await HttpGet(ct, "v1/landlord/transactions?page=" + pageNo.ToString(System.Globalization.CultureInfo.InvariantCulture) + extraUrl, TcJsonSerializerContext.Default.TcListResponseTcTransaction).ConfigureAwait(false); - var memory = new Memory(response.Entries); - return (memory, pageNo, response?.Pagination?.Total ?? 0); + var url = endpoint + "?page=" + pageNo.ToString(CultureInfo.InvariantCulture) + extraUrl; + var response = await HttpGet(ct, url, typeInfo).ConfigureAwait(false); + + var entries = response.Data? + .Select(item => + { + var attr = item.Attributes; + if (attr is not null) + { + attr.Id = item.Id; + } + + return attr!; + }) + .Where(a => a is not null) + .ToArray() ?? Array.Empty(); + + return (entries.AsMemory(), pageNo, response.Meta?.Pagination?.Total ?? 0); } private async Task HttpGet(CancellationToken ct, string uri, JsonTypeInfo typeInfo) From df0b9040e4b435e2360a1e193216d99893670d2c Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 17:20:43 -0500 Subject: [PATCH 10/17] Refactored and reorganized paginated source extensions: added new methods for `Contacts` and `Leases`, updated query parameter filters for consistency, and removed `TcTenantsPaginatedSourceExtensions`. --- .../TcContactsPaginatedSourceExtensions.cs | 46 +++++++++++++++++++ .../TcLeasesPaginatedSourceExtensions.cs | 37 +++++++++++++++ .../TcTenantsPaginatedSourceExtensions.cs | 36 --------------- ...TcTransactionsPaginatedSourceExtensions.cs | 20 ++++++-- .../TcUnitsPaginatedSourceExtensions.cs | 6 +-- 5 files changed, 101 insertions(+), 44 deletions(-) create mode 100644 src/Yllibed.TenantCloudClient/TcContactsPaginatedSourceExtensions.cs create mode 100644 src/Yllibed.TenantCloudClient/TcLeasesPaginatedSourceExtensions.cs delete mode 100644 src/Yllibed.TenantCloudClient/TcTenantsPaginatedSourceExtensions.cs diff --git a/src/Yllibed.TenantCloudClient/TcContactsPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcContactsPaginatedSourceExtensions.cs new file mode 100644 index 0000000..3d54ca1 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/TcContactsPaginatedSourceExtensions.cs @@ -0,0 +1,46 @@ +using Yllibed.TenantCloudClient.HttpMessages; + +namespace Yllibed.TenantCloudClient; + +public static class TcContactsPaginatedSourceExtensions +{ + public static IPaginatedSource OnlyTenants(this IPaginatedSource source) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[roles][]=tenant"); + } + + throw new ArgumentException("Invalid source.", nameof(source)); + } + + public static IPaginatedSource OnlyMovedIn(this IPaginatedSource source) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[tenant_contact_type]=moved_in"); + } + + throw new ArgumentException("Invalid source.", nameof(source)); + } + + public static IPaginatedSource OnlyProfessionals(this IPaginatedSource source) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[roles][]=professional"); + } + + throw new ArgumentException("Invalid source.", nameof(source)); + } + + public static IPaginatedSource OnlyArchived(this IPaginatedSource source) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[status]=archived"); + } + + throw new ArgumentException("Invalid source.", nameof(source)); + } +} diff --git a/src/Yllibed.TenantCloudClient/TcLeasesPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcLeasesPaginatedSourceExtensions.cs new file mode 100644 index 0000000..aeca993 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/TcLeasesPaginatedSourceExtensions.cs @@ -0,0 +1,37 @@ +using System.Globalization; +using Yllibed.TenantCloudClient.HttpMessages; + +namespace Yllibed.TenantCloudClient; + +public static class TcLeasesPaginatedSourceExtensions +{ + public static IPaginatedSource OnlyActive(this IPaginatedSource source) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[lease_status][]=active"); + } + + throw new ArgumentException("Invalid source.", nameof(source)); + } + + public static IPaginatedSource ForProperty(this IPaginatedSource source, long propertyId) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[property_id][]=" + propertyId.ToString(CultureInfo.InvariantCulture)); + } + + throw new ArgumentException("Invalid source.", nameof(source)); + } + + public static IPaginatedSource ForUnit(this IPaginatedSource source, long unitId) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[unit_id]=" + unitId.ToString(CultureInfo.InvariantCulture)); + } + + throw new ArgumentException("Invalid source.", nameof(source)); + } +} diff --git a/src/Yllibed.TenantCloudClient/TcTenantsPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcTenantsPaginatedSourceExtensions.cs deleted file mode 100644 index dc38149..0000000 --- a/src/Yllibed.TenantCloudClient/TcTenantsPaginatedSourceExtensions.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Yllibed.TenantCloudClient.HttpMessages; - -namespace Yllibed.TenantCloudClient; - -public static class TcTenantsPaginatedSourceExtensions -{ - public static IPaginatedSource OnlyMovedIn(this IPaginatedSource source) - { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=moved_in"); - } - - throw new ArgumentException("Invalid source.", nameof(source)); - } - - public static IPaginatedSource OnlyArchived(this IPaginatedSource source) - { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=archived"); - } - - throw new ArgumentException("Invalid source.", nameof(source)); - } - - public static IPaginatedSource OnlyNoLease(this IPaginatedSource source) - { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=no_lease"); - } - - throw new ArgumentException("Invalid source.", nameof(source)); - } -} diff --git a/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs index 7983030..b5ffc53 100644 --- a/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs +++ b/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs @@ -9,7 +9,7 @@ public static IPaginatedSource ForTenant(this IPaginatedSource paginatedSource) { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&client=" + tenantId.ToString(NumberFormatInfo.InvariantInfo)); + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[client_id]=" + tenantId.ToString(NumberFormatInfo.InvariantInfo)); } throw new ArgumentException("Invalid source.", nameof(source)); @@ -19,7 +19,7 @@ public static IPaginatedSource ForProperty(this IPaginatedSource< { if (source is PaginatedSource paginatedSource) { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&property=" + propertyId.ToString(NumberFormatInfo.InvariantInfo)); + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[property_id][]=" + propertyId.ToString(NumberFormatInfo.InvariantInfo)); } throw new ArgumentException("Invalid source.", nameof(source)); @@ -29,7 +29,7 @@ public static IPaginatedSource ForUnit(this IPaginatedSource paginatedSource) { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&unit=" + unitId.ToString(NumberFormatInfo.InvariantInfo)); + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[unit_id]=" + unitId.ToString(NumberFormatInfo.InvariantInfo)); } throw new ArgumentException("Invalid source.", nameof(source)); @@ -39,7 +39,7 @@ public static IPaginatedSource ForStatus(this IPaginatedSource paginatedSource) { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&status=" + status.ToSerializedString()); + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[status]=" + status.ToSerializedString()); } throw new ArgumentException("Invalid source.", nameof(source)); @@ -49,7 +49,17 @@ public static IPaginatedSource ForCategory(this IPaginatedSource< { if (source is PaginatedSource paginatedSource) { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&category=" + category.ToString().ToLowerInvariant()); + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[category][]=" + category.ToString().ToLowerInvariant()); + } + + throw new ArgumentException("Invalid source.", nameof(source)); + } + + public static IPaginatedSource SortByDateDescending(this IPaginatedSource source) + { + if (source is PaginatedSource paginatedSource) + { + return paginatedSource.ProjectedWithExtraUrl(url => url + "&sort=-date,-id"); } throw new ArgumentException("Invalid source.", nameof(source)); diff --git a/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs index a58348c..c3251d3 100644 --- a/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs +++ b/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs @@ -9,7 +9,7 @@ public static IPaginatedSource OnlyOccuped(this IPaginatedSource { if (source is PaginatedSource paginatedSource) { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=occuped"); + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[is_rented]=true"); } throw new ArgumentException("Invalid source.", nameof(source)); @@ -19,7 +19,7 @@ public static IPaginatedSource OnlyVacant(this IPaginatedSource { if (source is PaginatedSource paginatedSource) { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=vacant"); + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[is_rented]=false"); } throw new ArgumentException("Invalid source.", nameof(source)); @@ -29,7 +29,7 @@ public static IPaginatedSource ForProperty(this IPaginatedSource { if (source is PaginatedSource paginatedSource) { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&property=" + propertyId.ToString(NumberFormatInfo.InvariantInfo)); + return paginatedSource.ProjectedWithExtraUrl(url => url + "&filter[property_id][]=" + propertyId.ToString(NumberFormatInfo.InvariantInfo)); } throw new ArgumentException("Invalid source.", nameof(source)); From 7c880a627764dd9a8e8153007423cefce82b20e0 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 17:20:51 -0500 Subject: [PATCH 11/17] Updated tests to reflect refactor: replaced `Tenants` references with `Contacts`, added support for `Leases`, removed obsolete test for tenants without leases, and added new tests for leases and properties. --- .../Given_TcClient.cs | 74 +++++++++++++------ 1 file changed, 53 insertions(+), 21 deletions(-) diff --git a/src/Yllibed.TenantCloudClient.Tests/Given_TcClient.cs b/src/Yllibed.TenantCloudClient.Tests/Given_TcClient.cs index 90f2029..68d81ed 100644 --- a/src/Yllibed.TenantCloudClient.Tests/Given_TcClient.cs +++ b/src/Yllibed.TenantCloudClient.Tests/Given_TcClient.cs @@ -19,10 +19,10 @@ public async Task When_GettingUserInfo() } [TestMethod] - public async Task When_GettingAllTenants() + public async Task When_GettingAllContacts() { var client = new TcClient(TokenProvider); - var sut = client.Tenants; + var sut = client.Contacts; var all = await sut.GetAll(CancellationToken.None); @@ -31,10 +31,10 @@ public async Task When_GettingAllTenants() } [TestMethod] - public async Task When_GettingMovedInTenants() + public async Task When_GettingMovedInContacts() { var client = new TcClient(TokenProvider); - var sut = client.Tenants.OnlyMovedIn(); + var sut = client.Contacts.OnlyMovedIn(); var all = await sut.GetAll(CancellationToken.None); @@ -42,18 +42,6 @@ public async Task When_GettingMovedInTenants() all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); } - [TestMethod] - public async Task When_GettingMNoLeaseTenants() - { - var client = new TcClient(TokenProvider); - var sut = client.Tenants.OnlyNoLease(); - - var all = await sut.GetAll(CancellationToken.None); - - // Won't check for zero on this one, since it's normal for it to be zero - all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); - } - [TestMethod] public async Task When_GetProperties() { @@ -82,11 +70,11 @@ public async Task When_GetUnits() public async Task When_GetTransactionsForTenant() { var client = new TcClient(TokenProvider); - var firstTenantId = await GetFirstTenantId(client); + var firstContactId = await GetFirstContactId(client); var sut = client.Transactions .ForCategory(TcTransactionCategory.Income) - .ForTenant(firstTenantId); + .ForTenant(firstContactId); var all = await sut.GetAll(CancellationToken.None); all.Length.Should().NotBe(0); @@ -139,10 +127,48 @@ public async Task When_GetBalancePerProperty() all.AsEnumerable().Select(x => x.propertyId).Should().OnlyHaveUniqueItems(); } - private static async Task GetFirstTenantId(TcClient client) + [TestMethod] + public async Task When_GetLeases() + { + var client = new TcClient(TokenProvider); + var sut = client.Leases; + + var all = await sut.GetAll(CancellationToken.None); + + all.Length.Should().NotBe(0); + all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); + } + + [TestMethod] + public async Task When_GetActiveLeases() + { + var client = new TcClient(TokenProvider); + var sut = client.Leases.OnlyActive(); + + var all = await sut.GetAll(CancellationToken.None); + + all.Length.Should().NotBe(0); + all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); + } + + [TestMethod] + public async Task When_GetLeasesForProperty() { - var tenants = await client.Tenants.OnlyMovedIn().GetAll(CancellationToken.None, 1).ConfigureAwait(false); - return tenants.AsEnumerable().First().Id; + var client = new TcClient(TokenProvider); + var firstPropertyId = await GetFirstPropertyId(client); + + var sut = client.Leases.ForProperty(firstPropertyId); + + var all = await sut.GetAll(CancellationToken.None); + + // May be empty if no leases for that property, just verify unique IDs + all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); + } + + private static async Task GetFirstContactId(TcClient client) + { + var contacts = await client.Contacts.OnlyMovedIn().GetAll(CancellationToken.None, 1).ConfigureAwait(false); + return contacts.AsEnumerable().First().Id; } private static async Task GetFirstUnitId(TcClient client) @@ -150,4 +176,10 @@ private static async Task GetFirstUnitId(TcClient client) var units = await client.Units.OnlyOccuped().GetAll(CancellationToken.None, 1).ConfigureAwait(false); return units.AsEnumerable().First().Id; } + + private static async Task GetFirstPropertyId(TcClient client) + { + var properties = await client.Properties.GetAll(CancellationToken.None, 1).ConfigureAwait(false); + return properties.AsEnumerable().First().Id; + } } From d23186c4a002fc769c0af56b1e5648590ebd6bd0 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 18:00:29 -0500 Subject: [PATCH 12/17] Implemented `SecureTokenStore` for OS-native credential storage, added platform-specific backends (Windows DPAPI, macOS Keychain, Linux Secret Service), and updated `TcJsonSerializerContext` with `TcTokenSet` serialization support. --- .../HttpMessages/TcJsonSerializerContext.cs | 3 +- .../Internal/CliHelper.cs | 45 +++++ .../Internal/ISecureStorageBackend.cs | 10 + .../Internal/LinuxSecretServiceBackend.cs | 44 +++++ .../Internal/MacOsKeychainBackend.cs | 44 +++++ .../Internal/WindowsDpapiBackend.cs | 172 ++++++++++++++++++ .../SecureTokenStore.cs | 96 ++++++++++ .../SecureTokenStoreOptions.cs | 13 ++ 8 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 src/Yllibed.TenantCloudClient/Internal/CliHelper.cs create mode 100644 src/Yllibed.TenantCloudClient/Internal/ISecureStorageBackend.cs create mode 100644 src/Yllibed.TenantCloudClient/Internal/LinuxSecretServiceBackend.cs create mode 100644 src/Yllibed.TenantCloudClient/Internal/MacOsKeychainBackend.cs create mode 100644 src/Yllibed.TenantCloudClient/Internal/WindowsDpapiBackend.cs create mode 100644 src/Yllibed.TenantCloudClient/SecureTokenStore.cs create mode 100644 src/Yllibed.TenantCloudClient/SecureTokenStoreOptions.cs diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs index 26aac58..8fa3559 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.HttpMessages; [JsonSourceGenerationOptions( @@ -12,6 +10,7 @@ namespace Yllibed.TenantCloudClient.HttpMessages; [JsonSerializable(typeof(TcJsonApiResponse))] [JsonSerializable(typeof(TcJsonApiResponse))] [JsonSerializable(typeof(TcErrorResponse))] +[JsonSerializable(typeof(TcTokenSet))] internal partial class TcJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Yllibed.TenantCloudClient/Internal/CliHelper.cs b/src/Yllibed.TenantCloudClient/Internal/CliHelper.cs new file mode 100644 index 0000000..9a68e37 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/Internal/CliHelper.cs @@ -0,0 +1,45 @@ +using System.Diagnostics; + +namespace Yllibed.TenantCloudClient.Internal; + +/// +/// Async helper for running CLI processes with optional stdin, capturing stdout/stderr. +/// +internal static class CliHelper +{ + public static async Task<(int ExitCode, string Stdout, string Stderr)> RunAsync( + string fileName, string arguments, string? stdinData, CancellationToken ct) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = stdinData is not null, + UseShellExecute = false, + CreateNoWindow = true, + }, + }; + + process.Start(); + + if (stdinData is not null) + { + await process.StandardInput.WriteAsync(stdinData).ConfigureAwait(false); + process.StandardInput.Close(); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); + var stderrTask = process.StandardError.ReadToEndAsync(ct); + + await process.WaitForExitAsync(ct).ConfigureAwait(false); + + var stdout = await stdoutTask.ConfigureAwait(false); + var stderr = await stderrTask.ConfigureAwait(false); + + return (process.ExitCode, stdout, stderr); + } +} diff --git a/src/Yllibed.TenantCloudClient/Internal/ISecureStorageBackend.cs b/src/Yllibed.TenantCloudClient/Internal/ISecureStorageBackend.cs new file mode 100644 index 0000000..263798f --- /dev/null +++ b/src/Yllibed.TenantCloudClient/Internal/ISecureStorageBackend.cs @@ -0,0 +1,10 @@ +namespace Yllibed.TenantCloudClient.Internal; + +/// +/// Platform-specific backend for loading and saving raw credential bytes. +/// +internal interface ISecureStorageBackend +{ + Task LoadAsync(string serviceName, string accountKey, CancellationToken ct); + Task SaveAsync(string serviceName, string accountKey, byte[] data, CancellationToken ct); +} diff --git a/src/Yllibed.TenantCloudClient/Internal/LinuxSecretServiceBackend.cs b/src/Yllibed.TenantCloudClient/Internal/LinuxSecretServiceBackend.cs new file mode 100644 index 0000000..99d5cb3 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/Internal/LinuxSecretServiceBackend.cs @@ -0,0 +1,44 @@ +using System.Runtime.Versioning; + +namespace Yllibed.TenantCloudClient.Internal; + +/// +/// Linux backend using Secret Service via the secret-tool CLI. +/// Data is Base64-encoded before storage for robustness. +/// +[SupportedOSPlatform("linux")] +internal sealed class LinuxSecretServiceBackend : ISecureStorageBackend +{ + public async Task LoadAsync(string serviceName, string accountKey, CancellationToken ct) + { + var (exitCode, stdout, _) = await CliHelper.RunAsync( + "secret-tool", + $"lookup service \"{serviceName}\" account \"{accountKey}\"", + stdinData: null, + ct).ConfigureAwait(false); + + if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout)) + { + return null; + } + + return Convert.FromBase64String(stdout.Trim()); + } + + public async Task SaveAsync(string serviceName, string accountKey, byte[] data, CancellationToken ct) + { + var base64 = Convert.ToBase64String(data); + + var (exitCode, _, stderr) = await CliHelper.RunAsync( + "secret-tool", + $"store --label=\"TenantCloud Tokens\" service \"{serviceName}\" account \"{accountKey}\"", + stdinData: base64, + ct).ConfigureAwait(false); + + if (exitCode != 0) + { + throw new InvalidOperationException( + $"secret-tool store failed (exit code {exitCode}): {stderr.Trim()}"); + } + } +} diff --git a/src/Yllibed.TenantCloudClient/Internal/MacOsKeychainBackend.cs b/src/Yllibed.TenantCloudClient/Internal/MacOsKeychainBackend.cs new file mode 100644 index 0000000..6d40919 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/Internal/MacOsKeychainBackend.cs @@ -0,0 +1,44 @@ +using System.Runtime.Versioning; + +namespace Yllibed.TenantCloudClient.Internal; + +/// +/// macOS backend using the Keychain via the security CLI. +/// Data is Base64-encoded before storage for robustness. +/// +[SupportedOSPlatform("macos")] +internal sealed class MacOsKeychainBackend : ISecureStorageBackend +{ + public async Task LoadAsync(string serviceName, string accountKey, CancellationToken ct) + { + var (exitCode, stdout, _) = await CliHelper.RunAsync( + "security", + $"find-generic-password -s \"{serviceName}\" -a \"{accountKey}\" -w", + stdinData: null, + ct).ConfigureAwait(false); + + if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout)) + { + return null; + } + + return Convert.FromBase64String(stdout.Trim()); + } + + public async Task SaveAsync(string serviceName, string accountKey, byte[] data, CancellationToken ct) + { + var base64 = Convert.ToBase64String(data); + + var (exitCode, _, stderr) = await CliHelper.RunAsync( + "security", + $"add-generic-password -s \"{serviceName}\" -a \"{accountKey}\" -w \"{base64}\" -U", + stdinData: null, + ct).ConfigureAwait(false); + + if (exitCode != 0) + { + throw new InvalidOperationException( + $"security add-generic-password failed (exit code {exitCode}): {stderr.Trim()}"); + } + } +} diff --git a/src/Yllibed.TenantCloudClient/Internal/WindowsDpapiBackend.cs b/src/Yllibed.TenantCloudClient/Internal/WindowsDpapiBackend.cs new file mode 100644 index 0000000..7436652 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/Internal/WindowsDpapiBackend.cs @@ -0,0 +1,172 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; + +namespace Yllibed.TenantCloudClient.Internal; + +/// +/// Windows backend using DPAPI (CryptProtectData / CryptUnprotectData) +/// with encrypted files stored in %LOCALAPPDATA%/Yllibed/TenantCloud/. +/// +[SupportedOSPlatform("windows")] +internal sealed class WindowsDpapiBackend : ISecureStorageBackend +{ + public Task LoadAsync(string serviceName, string accountKey, CancellationToken ct) + { + var filePath = GetFilePath(accountKey); + + if (!File.Exists(filePath)) + { + return Task.FromResult(null); + } + + var encrypted = File.ReadAllBytes(filePath); + var entropy = GetEntropy(serviceName); + + var encryptedBlob = new DATA_BLOB(encrypted); + var entropyBlob = new DATA_BLOB(entropy); + var decryptedBlob = default(DATA_BLOB); + + try + { + if (!CryptUnprotectData( + ref encryptedBlob, + nint.Zero, + ref entropyBlob, + nint.Zero, + nint.Zero, + 0, + ref decryptedBlob)) + { + return Task.FromResult(null); + } + + var result = new byte[decryptedBlob.cbData]; + Marshal.Copy(decryptedBlob.pbData, result, 0, decryptedBlob.cbData); + return Task.FromResult(result); + } + finally + { + encryptedBlob.Free(); + entropyBlob.Free(); + decryptedBlob.Free(); + } + } + + public Task SaveAsync(string serviceName, string accountKey, byte[] data, CancellationToken ct) + { + var filePath = GetFilePath(accountKey); + + var dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + var entropy = GetEntropy(serviceName); + + var plaintextBlob = new DATA_BLOB(data); + var entropyBlob = new DATA_BLOB(entropy); + var encryptedBlob = default(DATA_BLOB); + + try + { + if (!CryptProtectData( + ref plaintextBlob, + null, + ref entropyBlob, + nint.Zero, + nint.Zero, + 0, + ref encryptedBlob)) + { + throw new InvalidOperationException( + $"CryptProtectData failed (error 0x{Marshal.GetLastPInvokeError():X8})."); + } + + var encrypted = new byte[encryptedBlob.cbData]; + Marshal.Copy(encryptedBlob.pbData, encrypted, 0, encryptedBlob.cbData); + + // Atomic write: temp + rename + var tempPath = filePath + ".tmp"; + try + { + File.WriteAllBytes(tempPath, encrypted); + File.Move(tempPath, filePath, overwrite: true); + } + catch + { + try { File.Delete(tempPath); } catch { /* best effort */ } + throw; + } + } + finally + { + plaintextBlob.Free(); + entropyBlob.Free(); + encryptedBlob.Free(); + } + + return Task.CompletedTask; + } + + private static string GetFilePath(string accountKey) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(localAppData, "Yllibed", "TenantCloud", $"tokens-{accountKey}.dpapi"); + } + + private static byte[] GetEntropy(string serviceName) => + Encoding.UTF8.GetBytes(serviceName); + + #region P/Invoke + + [StructLayout(LayoutKind.Sequential)] + private struct DATA_BLOB + { + public int cbData; + public nint pbData; + + public DATA_BLOB(byte[] data) + { + cbData = data.Length; + pbData = Marshal.AllocHGlobal(data.Length); + Marshal.Copy(data, 0, pbData, data.Length); + } + + public void Free() + { + if (pbData != nint.Zero) + { + Marshal.FreeHGlobal(pbData); + pbData = nint.Zero; + } + } + } + + [DllImport("Crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CryptProtectData( + ref DATA_BLOB pDataIn, + string? szDataDescr, + ref DATA_BLOB pOptionalEntropy, + nint pvReserved, + nint pPromptStruct, + int dwFlags, + ref DATA_BLOB pDataOut); + + [DllImport("Crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CryptUnprotectData( + ref DATA_BLOB pDataIn, + nint ppszDataDescr, + ref DATA_BLOB pOptionalEntropy, + nint pvReserved, + nint pPromptStruct, + int dwFlags, + ref DATA_BLOB pDataOut); + + #endregion +} diff --git a/src/Yllibed.TenantCloudClient/SecureTokenStore.cs b/src/Yllibed.TenantCloudClient/SecureTokenStore.cs new file mode 100644 index 0000000..09353f0 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/SecureTokenStore.cs @@ -0,0 +1,96 @@ +namespace Yllibed.TenantCloudClient; + +/// +/// implementation that stores tokens in the OS-native credential store: +/// DPAPI on Windows, Keychain on macOS, Secret Service on Linux. +/// +public sealed class SecureTokenStore : ITcTokenStore +{ + private readonly SecureTokenStoreOptions _options; + private readonly ISecureStorageBackend _backend; + private readonly SemaphoreSlim _gate = new(1, 1); + + /// + /// Creates a new . + /// + /// Optional configuration for service name and account key. + /// + /// Thrown when the current OS is not Windows, macOS, or Linux. + /// + public SecureTokenStore(SecureTokenStoreOptions? options = null) + { + _options = options ?? new SecureTokenStoreOptions(); + _backend = CreateBackend(); + } + + /// + /// Gets a value indicating whether the current operating system is supported. + /// + public static bool IsSupported => + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() || OperatingSystem.IsLinux(); + + /// + public async Task LoadAsync(CancellationToken ct) + { + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + var bytes = await _backend.LoadAsync( + _options.ServiceName, _options.AccountKey, ct).ConfigureAwait(false); + + if (bytes is null || bytes.Length == 0) + { + return null; + } + + return JsonSerializer.Deserialize(bytes, HttpMessages.TcJsonSerializerContext.Default.TcTokenSet); + } + catch + { + return null; + } + finally + { + _gate.Release(); + } + } + + /// + public async Task SaveAsync(TcTokenSet tokens, CancellationToken ct) + { + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + var bytes = JsonSerializer.SerializeToUtf8Bytes( + tokens, HttpMessages.TcJsonSerializerContext.Default.TcTokenSet); + + await _backend.SaveAsync( + _options.ServiceName, _options.AccountKey, bytes, ct).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + private static ISecureStorageBackend CreateBackend() + { + if (OperatingSystem.IsWindows()) + { + return new WindowsDpapiBackend(); + } + + if (OperatingSystem.IsMacOS()) + { + return new MacOsKeychainBackend(); + } + + if (OperatingSystem.IsLinux()) + { + return new LinuxSecretServiceBackend(); + } + + throw new PlatformNotSupportedException( + "SecureTokenStore is only supported on Windows, macOS, and Linux."); + } +} diff --git a/src/Yllibed.TenantCloudClient/SecureTokenStoreOptions.cs b/src/Yllibed.TenantCloudClient/SecureTokenStoreOptions.cs new file mode 100644 index 0000000..66b81b8 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/SecureTokenStoreOptions.cs @@ -0,0 +1,13 @@ +namespace Yllibed.TenantCloudClient; + +/// +/// Options for . +/// +public sealed class SecureTokenStoreOptions +{ + /// Service name used as the credential identifier in the OS credential store. + public string ServiceName { get; init; } = "Yllibed.TenantCloudClient"; + + /// Account key for distinguishing multiple credential entries. + public string AccountKey { get; init; } = "default"; +} From 7fb562fb0434c611173e366a175c4e471e5f644f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 18:00:40 -0500 Subject: [PATCH 13/17] Remove `.Cdp` namespace suffix from `ITcTokenStore` and `TcTokenSet`. --- .../ITcTokenStore.cs | 2 +- .../TcTokenSet.cs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) rename src/{Yllibed.TenantCloudClient.Cdp => Yllibed.TenantCloudClient}/ITcTokenStore.cs (84%) rename src/{Yllibed.TenantCloudClient.Cdp => Yllibed.TenantCloudClient}/TcTokenSet.cs (81%) diff --git a/src/Yllibed.TenantCloudClient.Cdp/ITcTokenStore.cs b/src/Yllibed.TenantCloudClient/ITcTokenStore.cs similarity index 84% rename from src/Yllibed.TenantCloudClient.Cdp/ITcTokenStore.cs rename to src/Yllibed.TenantCloudClient/ITcTokenStore.cs index 52a70ec..c3f23bd 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/ITcTokenStore.cs +++ b/src/Yllibed.TenantCloudClient/ITcTokenStore.cs @@ -1,4 +1,4 @@ -namespace Yllibed.TenantCloudClient.Cdp; +namespace Yllibed.TenantCloudClient; /// /// Persists TenantCloud auth tokens across sessions. diff --git a/src/Yllibed.TenantCloudClient.Cdp/TcTokenSet.cs b/src/Yllibed.TenantCloudClient/TcTokenSet.cs similarity index 81% rename from src/Yllibed.TenantCloudClient.Cdp/TcTokenSet.cs rename to src/Yllibed.TenantCloudClient/TcTokenSet.cs index 859bfc8..8bef30f 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/TcTokenSet.cs +++ b/src/Yllibed.TenantCloudClient/TcTokenSet.cs @@ -1,6 +1,4 @@ -using System.Text.Json.Serialization; - -namespace Yllibed.TenantCloudClient.Cdp; +namespace Yllibed.TenantCloudClient; /// /// Holds the three pieces required for TenantCloud API authentication and token refresh. From d39a8719a5d45acde4426309209d2e2511a04470 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 18:00:47 -0500 Subject: [PATCH 14/17] Consolidated `using` directives into `GlobalUsings.cs` files for shared namespaces. --- src/Yllibed.TenantCloudClient.Cdp/CdpBrowserDiscovery.cs | 3 --- src/Yllibed.TenantCloudClient.Cdp/CdpConnection.cs | 2 -- src/Yllibed.TenantCloudClient.Cdp/CdpJsonContext.cs | 3 --- src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpCookie.cs | 2 -- src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpError.cs | 2 -- .../CdpMessages/CdpEvaluateParams.cs | 2 -- .../CdpMessages/CdpEvaluateResult.cs | 2 -- .../CdpMessages/CdpExceptionDetails.cs | 2 -- .../CdpMessages/CdpGetCookiesParams.cs | 2 -- .../CdpMessages/CdpGetCookiesResult.cs | 2 -- .../CdpMessages/CdpRemoteObject.cs | 2 -- src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRequest.cs | 3 --- src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpResponse.cs | 3 --- src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpTarget.cs | 2 -- .../CdpMessages/TcRefreshRequest.cs | 2 -- .../CdpMessages/TcRefreshResponse.cs | 2 -- src/Yllibed.TenantCloudClient.Cdp/CdpTokenProvider.cs | 2 -- src/Yllibed.TenantCloudClient.Cdp/FileTokenStore.cs | 2 -- src/Yllibed.TenantCloudClient.Cdp/GlobalUsings.cs | 4 ++++ src/Yllibed.TenantCloudClient.Cdp/JwtHelper.cs | 2 -- src/Yllibed.TenantCloudClient.Cdp/TcTokenRefresher.cs | 2 -- src/Yllibed.TenantCloudClient/GlobalUsings.cs | 4 ++++ .../HttpMessages/JsonAutoLongConverter.cs | 2 -- .../HttpMessages/JsonAutoNullableLongConverter.cs | 2 -- .../HttpMessages/JsonDecimalConverter.cs | 3 --- .../HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs | 2 -- .../JsonStringDateToNullableDateTimeOffsetConverter.cs | 2 -- .../HttpMessages/JsonStringToEnumConverter.cs | 3 --- .../HttpMessages/JsonTcTransactionStatusConverter.cs | 3 --- src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs | 1 - src/Yllibed.TenantCloudClient/HttpMessages/TcErrorResponse.cs | 2 -- src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiItem.cs | 2 -- src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiMeta.cs | 2 -- .../HttpMessages/TcJsonApiPagination.cs | 2 -- .../HttpMessages/TcJsonApiResponse.cs | 2 -- src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs | 2 -- src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs | 1 - src/Yllibed.TenantCloudClient/HttpMessages/TcTenant.cs | 2 -- src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs | 2 -- src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs | 2 -- src/Yllibed.TenantCloudClient/ITcClient.cs | 2 -- src/Yllibed.TenantCloudClient/TcClient.cs | 2 -- .../TcContactsPaginatedSourceExtensions.cs | 2 -- .../TcLeasesPaginatedSourceExtensions.cs | 1 - .../TcTransactionsPaginatedSourceExtensions.cs | 1 - .../TcUnitsPaginatedSourceExtensions.cs | 1 - 46 files changed, 8 insertions(+), 90 deletions(-) create mode 100644 src/Yllibed.TenantCloudClient.Cdp/GlobalUsings.cs create mode 100644 src/Yllibed.TenantCloudClient/GlobalUsings.cs diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpBrowserDiscovery.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpBrowserDiscovery.cs index ba5d6dd..67c1f09 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpBrowserDiscovery.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpBrowserDiscovery.cs @@ -1,6 +1,3 @@ -using System.Text.Json; -using Yllibed.TenantCloudClient.Cdp.CdpMessages; - namespace Yllibed.TenantCloudClient.Cdp; internal static class CdpBrowserDiscovery diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpConnection.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpConnection.cs index 4915331..5a4ecf9 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpConnection.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpConnection.cs @@ -1,8 +1,6 @@ using System.Collections.Concurrent; using System.Net.WebSockets; -using System.Text.Json; using System.Text.Json.Serialization.Metadata; -using Yllibed.TenantCloudClient.Cdp.CdpMessages; namespace Yllibed.TenantCloudClient.Cdp; diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpJsonContext.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpJsonContext.cs index faacf92..a157078 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpJsonContext.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpJsonContext.cs @@ -1,6 +1,3 @@ -using System.Text.Json.Serialization; -using Yllibed.TenantCloudClient.Cdp.CdpMessages; - namespace Yllibed.TenantCloudClient.Cdp; [JsonSerializable(typeof(CdpTarget[]))] diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpCookie.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpCookie.cs index c311a84..9af36bf 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpCookie.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpCookie.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; internal sealed class CdpCookie diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpError.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpError.cs index 0921129..f94ba2c 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpError.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpError.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; internal sealed class CdpError diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateParams.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateParams.cs index ee20291..10353dc 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateParams.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateParams.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; internal sealed class CdpEvaluateParams diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateResult.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateResult.cs index 67d6e8f..cbab78c 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateResult.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateResult.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; internal sealed class CdpEvaluateResult diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpExceptionDetails.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpExceptionDetails.cs index 83c4687..1b22724 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpExceptionDetails.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpExceptionDetails.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; internal sealed class CdpExceptionDetails diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesParams.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesParams.cs index 11ebb5d..d17ce49 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesParams.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesParams.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; internal sealed class CdpGetCookiesParams diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesResult.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesResult.cs index d25eaad..df25ea3 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesResult.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesResult.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; internal sealed class CdpGetCookiesResult diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRemoteObject.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRemoteObject.cs index 9ff1a2c..465c948 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRemoteObject.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRemoteObject.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; internal sealed class CdpRemoteObject diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRequest.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRequest.cs index fb93895..525cd16 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRequest.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRequest.cs @@ -1,6 +1,3 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; internal sealed class CdpRequest diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpResponse.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpResponse.cs index 7b2e5dc..706954b 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpResponse.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpResponse.cs @@ -1,6 +1,3 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; internal sealed class CdpResponse diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpTarget.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpTarget.cs index 9a2e8f2..99fb5a5 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpTarget.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpTarget.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; internal sealed class CdpTarget diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshRequest.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshRequest.cs index 9973e83..6624f7d 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshRequest.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshRequest.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; internal sealed class TcRefreshRequest diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshResponse.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshResponse.cs index d72acd2..6388db7 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshResponse.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshResponse.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.Cdp.CdpMessages; internal sealed class TcRefreshResponse diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpTokenProvider.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpTokenProvider.cs index 51dae8f..00e12d0 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/CdpTokenProvider.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpTokenProvider.cs @@ -1,6 +1,4 @@ using System.Diagnostics; -using System.Text.Json; -using Yllibed.TenantCloudClient.Cdp.CdpMessages; namespace Yllibed.TenantCloudClient.Cdp; diff --git a/src/Yllibed.TenantCloudClient.Cdp/FileTokenStore.cs b/src/Yllibed.TenantCloudClient.Cdp/FileTokenStore.cs index 008ec16..23c667a 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/FileTokenStore.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/FileTokenStore.cs @@ -1,5 +1,3 @@ -using System.Text.Json; - namespace Yllibed.TenantCloudClient.Cdp; /// diff --git a/src/Yllibed.TenantCloudClient.Cdp/GlobalUsings.cs b/src/Yllibed.TenantCloudClient.Cdp/GlobalUsings.cs new file mode 100644 index 0000000..07bffaa --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using System.Text.Json; +global using System.Text.Json.Serialization; +global using Yllibed.TenantCloudClient; +global using Yllibed.TenantCloudClient.Cdp.CdpMessages; diff --git a/src/Yllibed.TenantCloudClient.Cdp/JwtHelper.cs b/src/Yllibed.TenantCloudClient.Cdp/JwtHelper.cs index 3ee46d3..d75ab34 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/JwtHelper.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/JwtHelper.cs @@ -1,5 +1,3 @@ -using System.Text.Json; - namespace Yllibed.TenantCloudClient.Cdp; internal static class JwtHelper diff --git a/src/Yllibed.TenantCloudClient.Cdp/TcTokenRefresher.cs b/src/Yllibed.TenantCloudClient.Cdp/TcTokenRefresher.cs index 49a7600..77dbdbf 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/TcTokenRefresher.cs +++ b/src/Yllibed.TenantCloudClient.Cdp/TcTokenRefresher.cs @@ -1,7 +1,5 @@ using System.Net.Http.Headers; using System.Text; -using System.Text.Json; -using Yllibed.TenantCloudClient.Cdp.CdpMessages; namespace Yllibed.TenantCloudClient.Cdp; diff --git a/src/Yllibed.TenantCloudClient/GlobalUsings.cs b/src/Yllibed.TenantCloudClient/GlobalUsings.cs new file mode 100644 index 0000000..69cb251 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using System.Text.Json; +global using System.Text.Json.Serialization; +global using Yllibed.TenantCloudClient.HttpMessages; +global using Yllibed.TenantCloudClient.Internal; diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoLongConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoLongConverter.cs index a3abc01..f4d00a9 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoLongConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoLongConverter.cs @@ -1,6 +1,4 @@ using System.Globalization; -using System.Text.Json; -using System.Text.Json.Serialization; namespace Yllibed.TenantCloudClient.HttpMessages; diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoNullableLongConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoNullableLongConverter.cs index d81d5ad..b2ec725 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoNullableLongConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoNullableLongConverter.cs @@ -1,6 +1,4 @@ using System.Globalization; -using System.Text.Json; -using System.Text.Json.Serialization; namespace Yllibed.TenantCloudClient.HttpMessages; diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonDecimalConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonDecimalConverter.cs index 7904cbd..53be671 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonDecimalConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonDecimalConverter.cs @@ -1,6 +1,3 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.HttpMessages; public class JsonDecimalConverter : JsonConverter diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs index c09d61a..91fcee8 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs @@ -1,6 +1,4 @@ using System.Globalization; -using System.Text.Json; -using System.Text.Json.Serialization; namespace Yllibed.TenantCloudClient.HttpMessages; diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToNullableDateTimeOffsetConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToNullableDateTimeOffsetConverter.cs index af69bed..540fb2e 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToNullableDateTimeOffsetConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToNullableDateTimeOffsetConverter.cs @@ -1,6 +1,4 @@ using System.Globalization; -using System.Text.Json; -using System.Text.Json.Serialization; namespace Yllibed.TenantCloudClient.HttpMessages; diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringToEnumConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringToEnumConverter.cs index f0a0a09..f9607dc 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringToEnumConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringToEnumConverter.cs @@ -1,6 +1,3 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.HttpMessages; public class JsonStringToEnumConverter : JsonConverter diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcTransactionStatusConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcTransactionStatusConverter.cs index f257b23..9c8afcb 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcTransactionStatusConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcTransactionStatusConverter.cs @@ -1,6 +1,3 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.HttpMessages; public class JsonTcTransactionStatusConverter : JsonConverter diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs index 52b7c5c..a84d48f 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs @@ -1,4 +1,3 @@ -using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace Yllibed.TenantCloudClient.HttpMessages; diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcErrorResponse.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcErrorResponse.cs index ecb324b..b6a46f0 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcErrorResponse.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcErrorResponse.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.HttpMessages; public class TcErrorResponse diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiItem.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiItem.cs index 31c1acd..67917d7 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiItem.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiItem.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.HttpMessages; internal class TcJsonApiItem diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiMeta.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiMeta.cs index cd5ac70..368ac22 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiMeta.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiMeta.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.HttpMessages; internal class TcJsonApiMeta diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiPagination.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiPagination.cs index f583221..f4d78f0 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiPagination.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiPagination.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.HttpMessages; internal class TcJsonApiPagination diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiResponse.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiResponse.cs index 651f6fe..f9c033f 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiResponse.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiResponse.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.HttpMessages; internal class TcJsonApiResponse diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs index 547a5aa..da3e002 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.HttpMessages; public class TcLease : IHasId diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs index bed1417..ccde902 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Text.Json.Serialization; namespace Yllibed.TenantCloudClient.HttpMessages; diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcTenant.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcTenant.cs index fb0e99f..fab5226 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcTenant.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcTenant.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.HttpMessages; public class TcTenant diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs index de2344a..12a2595 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.HttpMessages; public class TcTransaction : IHasId diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs index 7a540d7..b9bd484 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Yllibed.TenantCloudClient.HttpMessages; public class TcUnit : IHasId diff --git a/src/Yllibed.TenantCloudClient/ITcClient.cs b/src/Yllibed.TenantCloudClient/ITcClient.cs index a272b58..9a07943 100644 --- a/src/Yllibed.TenantCloudClient/ITcClient.cs +++ b/src/Yllibed.TenantCloudClient/ITcClient.cs @@ -1,5 +1,3 @@ -using Yllibed.TenantCloudClient.HttpMessages; - namespace Yllibed.TenantCloudClient; /// diff --git a/src/Yllibed.TenantCloudClient/TcClient.cs b/src/Yllibed.TenantCloudClient/TcClient.cs index e46ad81..6fee874 100644 --- a/src/Yllibed.TenantCloudClient/TcClient.cs +++ b/src/Yllibed.TenantCloudClient/TcClient.cs @@ -2,9 +2,7 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; -using System.Text.Json; using System.Text.Json.Serialization.Metadata; -using Yllibed.TenantCloudClient.HttpMessages; namespace Yllibed.TenantCloudClient; diff --git a/src/Yllibed.TenantCloudClient/TcContactsPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcContactsPaginatedSourceExtensions.cs index 3d54ca1..c5b0199 100644 --- a/src/Yllibed.TenantCloudClient/TcContactsPaginatedSourceExtensions.cs +++ b/src/Yllibed.TenantCloudClient/TcContactsPaginatedSourceExtensions.cs @@ -1,5 +1,3 @@ -using Yllibed.TenantCloudClient.HttpMessages; - namespace Yllibed.TenantCloudClient; public static class TcContactsPaginatedSourceExtensions diff --git a/src/Yllibed.TenantCloudClient/TcLeasesPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcLeasesPaginatedSourceExtensions.cs index aeca993..13a59da 100644 --- a/src/Yllibed.TenantCloudClient/TcLeasesPaginatedSourceExtensions.cs +++ b/src/Yllibed.TenantCloudClient/TcLeasesPaginatedSourceExtensions.cs @@ -1,5 +1,4 @@ using System.Globalization; -using Yllibed.TenantCloudClient.HttpMessages; namespace Yllibed.TenantCloudClient; diff --git a/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs index b5ffc53..defe047 100644 --- a/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs +++ b/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs @@ -1,5 +1,4 @@ using System.Globalization; -using Yllibed.TenantCloudClient.HttpMessages; namespace Yllibed.TenantCloudClient; diff --git a/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs index c3251d3..4d4eb83 100644 --- a/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs +++ b/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs @@ -1,5 +1,4 @@ using System.Globalization; -using Yllibed.TenantCloudClient.HttpMessages; namespace Yllibed.TenantCloudClient; From feede530d2f1f25930e26d0dd66d3e1797b1c4e8 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 18:14:53 -0500 Subject: [PATCH 15/17] Updated copyright year to auto-update dynamically in project files. --- .../Yllibed.TenantCloudClient.Cdp.csproj | 2 +- src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Yllibed.TenantCloudClient.Cdp/Yllibed.TenantCloudClient.Cdp.csproj b/src/Yllibed.TenantCloudClient.Cdp/Yllibed.TenantCloudClient.Cdp.csproj index 2870991..28d2f7b 100644 --- a/src/Yllibed.TenantCloudClient.Cdp/Yllibed.TenantCloudClient.Cdp.csproj +++ b/src/Yllibed.TenantCloudClient.Cdp/Yllibed.TenantCloudClient.Cdp.csproj @@ -17,7 +17,7 @@ Yllibed.TenantCloudClient. Extracts auth tokens from an existing browser session — no external NuGet dependencies. - Copyright (C) 2019 - Carl de Billy - All Rights Reserved + Copyright (C) 2019-$([System.DateTime]::Now.Year) - Carl de Billy - All Rights Reserved https://github.com/yllibed/TenantCloudClient https://github.com/yllibed/TenantCloudClient git diff --git a/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj b/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj index 7f8f1f3..2b89559 100644 --- a/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj +++ b/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj @@ -17,7 +17,7 @@ ** THIS IS NOT AN OFFICIAL API CLIENT FOR TENANTCLOUD ** (anyway there's no such thing as an official API yet) - Copyright (C) 2019 - Carl de Billy - All Rights Reserved + Copyright (C) 2019-$([System.DateTime]::Now.Year) - Carl de Billy - All Rights Reserved https://github.com/yllibed/TenantCloudClient https://github.com/yllibed/TenantCloudClient git From 3793f26a1aae4eb9c7c419905f48edf7922ce089 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 18:15:02 -0500 Subject: [PATCH 16/17] Add service collection extensions for dependency injection support. - Registered `CdpTokenProvider` for CDP-based token extraction. - Added `AddTenantCloudClient` and `AddSecureTokenStore` methods for streamlined service registration. - Updated packages to include `Microsoft.Extensions.DependencyInjection.Abstractions`. --- src/Directory.Packages.props | 1 + .../CdpServiceCollectionExtensions.cs | 30 ++++++++++++++++ .../TenantCloudServiceCollectionExtensions.cs | 36 +++++++++++++++++++ .../Yllibed.TenantCloudClient.csproj | 4 +++ 4 files changed, 71 insertions(+) create mode 100644 src/Yllibed.TenantCloudClient.Cdp/CdpServiceCollectionExtensions.cs create mode 100644 src/Yllibed.TenantCloudClient/TenantCloudServiceCollectionExtensions.cs diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index aa81c5a..46c1aad 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpServiceCollectionExtensions.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpServiceCollectionExtensions.cs new file mode 100644 index 0000000..be3c5e7 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Yllibed.TenantCloudClient.Cdp; + +/// +/// Extension methods for registering CDP-based TenantCloud services in an . +/// +public static class CdpServiceCollectionExtensions +{ + /// + /// Registers using the CDP-based + /// that extracts tokens from a running Chromium browser session. + /// If an is registered, it will be used for token persistence automatically. + /// + public static IServiceCollection AddCdpTokenProvider( + this IServiceCollection services, + Action? configure = null) + { + services.AddSingleton(sp => + { + var options = new CdpTokenProviderOptions + { + TokenStore = sp.GetService(), + }; + configure?.Invoke(options); + return new CdpTokenProvider(options); + }); + return services; + } +} diff --git a/src/Yllibed.TenantCloudClient/TenantCloudServiceCollectionExtensions.cs b/src/Yllibed.TenantCloudClient/TenantCloudServiceCollectionExtensions.cs new file mode 100644 index 0000000..cea2d27 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/TenantCloudServiceCollectionExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Yllibed.TenantCloudClient; + +/// +/// Extension methods for registering TenantCloud services in an . +/// +public static class TenantCloudServiceCollectionExtensions +{ + /// + /// Registers (implemented by ). + /// An must already be registered in the container. + /// + public static IServiceCollection AddTenantCloudClient(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } + + /// + /// Registers using the OS-native + /// (DPAPI on Windows, Keychain on macOS, Secret Service on Linux). + /// + public static IServiceCollection AddSecureTokenStore( + this IServiceCollection services, + Action? configure = null) + { + services.AddSingleton(sp => + { + var options = new SecureTokenStoreOptions(); + configure?.Invoke(options); + return new SecureTokenStore(options); + }); + return services; + } +} diff --git a/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj b/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj index 2b89559..a31ab2c 100644 --- a/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj +++ b/src/Yllibed.TenantCloudClient/Yllibed.TenantCloudClient.csproj @@ -29,6 +29,10 @@ + + + + From 1750fabd7b954b086cf86049505100829bd60011 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 15 Feb 2026 18:15:15 -0500 Subject: [PATCH 17/17] Update README with improved documentation and usage examples. - Reorganized and rephrased for clarity and consistency. - Added dependency injection and custom provider examples. - Detailed authentication mechanism, token persistence, and built-in storage options. - Expanded API references with collection filters and paginated source details. --- README.md | 333 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 198 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index 6553237..409b9df 100644 --- a/README.md +++ b/README.md @@ -1,145 +1,208 @@ -# Yllibed's TenantCloud API Client Library -This is an unofficial _dotnet_ API client library to connect to [TenantCloud](https://tenantcloud.com), -a cheap / free online Rental Accounting and Management system. +# Yllibed.TenantCloudClient + +Unofficial .NET client library for [TenantCloud](https://tenantcloud.com), a rental property management platform. [![Build Status](https://dev.azure.com/yllibed/TenantCloudClient/_apis/build/status/yllibed.TenantCloudClient?branchName=master)](https://dev.azure.com/yllibed/TenantCloudClient/_build/latest?definitionId=1&branchName=master) [![Nuget](https://img.shields.io/nuget/dt/Yllibed.TenantCloudClient.svg?label=nuget.org)](https://www.nuget.org/packages/Yllibed.TenantCloudClient) -> Note: the goal of this API right now is to query the TenantCloud system. There's no way to make updates to data yet. +> **This is not an official TenantCloud product.** TenantCloud does not provide a public API; this library works against their internal endpoints. -## Quickstart +## Packages -1. Add a reference to the [`Yllibed.TenantCloudClient`](https://www.nuget.org/packages/Yllibed.TenantCloudClient/) nuget package in the project -2. Create a TenantCloud context: - ``` csharp - var tcContext = new InMemoryTcContext("username@domain.tld", "password"); - ``` -3. Make a call: - ``` csharp - public async RefreshTenants(CancellationToken ct, TenantCloudContext tcContext) - { - var client = new TcClient(tcContext); - var activeTenants = await client.Tenants.GetAll(ct); - - // do something funny with activeTenants here... - } - ``` - -## Features - -* Coded using `DOTNETSTANDARD2.1`, it means it works on: - * Dotnet Core 3.0+ - * Xamarin (iOS 12.16+ & Android 10+) - * Most other _Mono_ 6.4+ environment, except _WebAssembly_ like _Blazor_ or [Uno.BootStrapper](https://github.com/nventive/Uno.Wasm.Bootstrap) because they need a custom http handler - open - an issue if you need support for those. -* Will automatically renew the authentication token. -* Absolutely no external dependency (except `System.Text.Json` but it's now part of the framework) -* No enforced patterns: your code is responsible for the token persistence and the security of credentials. -* Compatible with most/all IoC containers. - -## API Details - -### `ITcClient`: - -Implemented by the class `Yllibed.TenantCloudClient.TcClient`. - -| Member | Type | Usage | -| ------------------------- | ----------------------------------- | ------------------------------------------------------------ | -| `GetUserInfo` (method) | `Task` | Get information about the current signed-in user. | -| `Tenants` (property) | `IPaginatedSource` | Get information about tenants | -| `Properties` (property) | `IPaginatedSource` | Get information about properties | -| `Units` (property) | `IPaginatedSource` | Get information about rented units | -| `Transactions` (property) | `IPaginatedSource` | Get transactions. Transactions are always in reversed chronologic order. | - -### `IPaginatedSource`: - -This interface let you get the `T` items from the API. Results are actually fetched when the `.GetAll()` method is used. - -* It's possible to specify a `maxResults` to the `.GetAll()` method to limit the number of results. The TenantCloud API will be fetched using their pagination system until the `maxResults` is reached. This means the `.GetAll()` can return more items than speficied, since it won't slice the last page to the number of `maxResults`. Example: - - ``` csharp - var results = await tcCLient.Transactions - .ForCategory(TcTransactionCategory.Expense) - .ForStatus(TcTransactionStatus.Overdue) - .GetAll(ct, maxResults: 20); - ``` - - - -* The `.GetAll()` method is returning the type `ReadOnlySequence`, which is specialized in returning a list with multiple segments (one per fetched page). If you need to process the results using standard LINQ operators, there's a `.AsEnumerable()` extension method available for that. Example: - - ``` csharp - var results = await tcClient.Tenants.OnlyNoLease().GetAll(ct); - var nameEmailsAndPhones = results // results is of type ReadOnlySequence - .AsEnumerable() // required to use LINQ operators - .Select(t=>(t.Name, t.ValidEmails, t.ValidPhones)) - .ToArray(); - ``` - -### `Tenants` (of type `IPaginatedSource`) - -Will return all non-archived tenants. - -* `.OnlyMovedIn()` to filter to "moved in" tenants (with at lease one active lease) -* `.OnlyArchived()` to get archived tenants (this one is not filtering the result, but will return archived instead) -* `.OnlyNoLease()` to filter to tenants without active leases (not "moved in") - -## `Properties` (of type `IPaginatedSource`) - -Will return all active (non-archived) properties. No extension methods for this yet. - -## `Units` (of type `IPaginatedSource`) - -Will return tenants. Following filters are possible: - -* `.OnlyOccuped()` to filter to units with at least one active lease -* `.OnlyVacant()` to filter to vacant units -* `.ForProperty(propertyId)` to filter for a specific property - -## `Transactions` (of type `IPaginatedSource`) - -Will return transactions in reversed chronological order. Following filters are possible: - -* `.ForTenant(tenantId)` to filter for a specific tenant -* `.ForProperty(propertyId)` to filter for a specific property -* `.ForUnit(unitId)` to filter for a specific unit -* `.ForStatus(status)` to filter to a specific `TcTransactionStatus`. Possible values: - * `TcTransactionStatus.Due` - * `TcTransactionStatus.Paid` - * `TcTransactionStatus.Partial` - * `TcTransactionStatus.Pending` - * `TcTransactionStatus.Void` - * `TcTransactionStatus.WithBalance` - * `TcTransactionStatus.Overdue` - * `TcTransactionStatus.Waive` -* `.ForCategory(category)` to filter to a specific `TcTransactionCategory`. Possible values: - * `TcTransactionCategory.Income` - * `TcTransactionCategory.Expense` - * `TcTransactionCategory.Refund` - * `TcTransactionCategory.Credits` - * `TcTransactionCategory.Liability` - -Example usages of `.Transactions`: - -``` csharp -// Check if a tenant is having any overdue lease -var isHavingOverdue = (await tcClient.Transactions - .ForCategory(TcTransactionCategory.Income) - .ForStatus(TcTransactionStatus.Overdue) - .ForTenant(tenantId) - .GetAll(ct, maxResults: 1)) - .AsEnumerable() - .Any(); +| Package | Description | +|---------|-------------| +| [`Yllibed.TenantCloudClient`](https://www.nuget.org/packages/Yllibed.TenantCloudClient/) | Core library: API client, token store abstractions, and OS-native secure storage | +| [`Yllibed.TenantCloudClient.Cdp`](https://www.nuget.org/packages/Yllibed.TenantCloudClient.Cdp/) | Chrome DevTools Protocol token provider (extracts tokens from a running browser) | + +Both packages target **net8.0** and **net10.0** with no external runtime dependencies beyond `System.Text.Json` and `Microsoft.Extensions.DependencyInjection.Abstractions`. + +## Quick start + +### With dependency injection + +```csharp +services + .AddSecureTokenStore() // ITcTokenStore → OS credential store + .AddCdpTokenProvider() // ITcAuthTokenProvider → browser extraction + auto-refresh + .AddTenantCloudClient(); // ITcClient → TcClient +``` + +Then inject `ITcClient` wherever you need it: + +```csharp +public class MyService(ITcClient tc) +{ + public async Task WhoAmI(CancellationToken ct) + => await tc.GetUserInfo(ct); +} +``` + +### Without dependency injection + +```csharp +var tokenStore = new SecureTokenStore(); +var tokenProvider = new CdpTokenProvider(new CdpTokenProviderOptions +{ + TokenStore = tokenStore, + AllowInteractiveLogin = true, +}); + +using var client = new TcClient(tokenProvider); +var user = await client.GetUserInfo(ct); +``` + +## Authentication + +`TcClient` requires an `ITcAuthTokenProvider` to supply Bearer tokens. The library does not store or manage credentials directly. + +```csharp +public interface ITcAuthTokenProvider +{ + Task GetToken(CancellationToken ct); + Task OnTokenRejected(CancellationToken ct, string rejectedToken); +} +``` + +### Built-in: `CdpTokenProvider` + +Provided by the **Yllibed.TenantCloudClient.Cdp** package. Extracts auth tokens from a running Chromium browser via the Chrome DevTools Protocol, with automatic JWT refresh. + +```csharp +services.AddCdpTokenProvider(options => +{ + options.DebugPort = 9222; // CDP debug port (default) + options.AllowInteractiveLogin = true; // launch a browser if no session found +}); +``` + +The provider follows a multi-step strategy: +1. In-memory cache (if the JWT is still valid) +2. Token store (load + refresh if expired) +3. CDP extraction from an existing browser tab on `app.tenantcloud.com` +4. Interactive login (if `AllowInteractiveLogin` is enabled) — launches a browser window and waits for the user to sign in + +### Custom provider + +Implement `ITcAuthTokenProvider` and register it before calling `AddTenantCloudClient()`: + +```csharp +services.AddSingleton(); +services.AddTenantCloudClient(); +``` + +## Token persistence + +`ITcTokenStore` allows tokens to survive across process restarts. Both the core library and the CDP provider can use it. + +```csharp +public interface ITcTokenStore +{ + Task LoadAsync(CancellationToken ct); + Task SaveAsync(TcTokenSet tokens, CancellationToken ct); +} +``` + +### Built-in stores + +| Store | Package | Description | +|-------|---------|-------------| +| `SecureTokenStore` | Core | OS-native credential storage: **DPAPI** (Windows), **Keychain** (macOS), **Secret Service** (Linux) | +| `FileTokenStore` | Cdp | Plain JSON file with atomic writes (useful for headless/CI scenarios) | + +```csharp +// OS-native secure storage (recommended) +services.AddSecureTokenStore(); + +// With custom options +services.AddSecureTokenStore(options => +{ + options.ServiceName = "MyApp"; + options.AccountKey = "production"; +}); + +// Or file-based (from the Cdp package, register manually) +services.AddSingleton(new FileTokenStore("/path/to/tokens.json")); +``` + +### Custom store + +Implement `ITcTokenStore` to persist tokens wherever you need (database, Azure Key Vault, etc.): -// Get total balance of per property -var balancePerProperty = (await tcClient.Transactions - .ForCategory(TcTransactionCategory.Income) - .ForStatus(TcTransactionStatus.WithBalance) - .GetAll(ct)) +```csharp +services.AddSingleton(); +``` + +## API reference + +### `ITcClient` + +| Member | Type | Description | +|--------|------|-------------| +| `GetUserInfo(ct)` | `Task` | Current signed-in user info | +| `Contacts` | `IPaginatedSource` | Contacts (tenants, professionals) | +| `Properties` | `IPaginatedSource` | Properties | +| `Units` | `IPaginatedSource` | Rental units | +| `Transactions` | `IPaginatedSource` | Financial transactions | +| `Leases` | `IPaginatedSource` | Leases | + +### Paginated sources + +Each collection is an `IPaginatedSource`. Call `.GetAll(ct)` to fetch all pages, or `.GetAll(ct, maxResults: n)` to cap the fetch: + +```csharp +var contacts = await client.Contacts.OnlyMovedIn().GetAll(ct); +``` + +The result is a `ReadOnlySequence` (one segment per API page). Use `.AsEnumerable()` to bridge to LINQ: + +```csharp +var names = (await client.Contacts.GetAll(ct)) .AsEnumerable() - .Where(t => t.PropertyId != null) // only property-specific income - .GroupBy(t => (long)t.PropertyId, t => t.Balance) // group them - .Select(g => (property: g.Key, balance: g.Sum())) // summarize - .ToArray(); // create final array + .Select(c => c.Name) + .ToArray(); +``` +### Filters + +Filters are chainable extension methods that narrow the API query before fetching. + +**Contacts** +- `.OnlyTenants()` — tenant contacts only +- `.OnlyMovedIn()` — tenants with active leases +- `.OnlyProfessionals()` — professional contacts only +- `.OnlyArchived()` — archived contacts + +**Leases** +- `.OnlyActive()` — active leases +- `.ForProperty(propertyId)` — filter by property +- `.ForUnit(unitId)` — filter by unit + +**Units** +- `.OnlyOccuped()` — units with active leases +- `.OnlyVacant()` — vacant units +- `.ForProperty(propertyId)` — filter by property + +**Transactions** +- `.ForTenant(tenantId)` — filter by tenant +- `.ForProperty(propertyId)` — filter by property +- `.ForUnit(unitId)` — filter by unit +- `.ForStatus(TcTransactionStatus)` — filter by status (`Due`, `Paid`, `Partial`, `Pending`, `Void`, `WithBalance`, `Overdue`, `Waive`) +- `.ForCategory(TcTransactionCategory)` — filter by category (`Income`, `Expense`, `Refund`, `Credits`, `Liability`) +- `.SortByDateDescending()` — reverse chronological order + +### Example: overdue income per property + +```csharp +var balancePerProperty = (await client.Transactions + .ForCategory(TcTransactionCategory.Income) + .ForStatus(TcTransactionStatus.WithBalance) + .GetAll(ct)) + .AsEnumerable() + .Where(t => t.PropertyId != null) + .GroupBy(t => (long)t.PropertyId!, t => t.Balance) + .Select(g => new { PropertyId = g.Key, Balance = g.Sum() }) + .ToArray(); ``` +## License + +MIT