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/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 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..46c1aad --- /dev/null +++ b/src/Directory.Packages.props @@ -0,0 +1,22 @@ + + + + true + + + + + + + + + + + + + + + + + + diff --git a/src/TenantCloud.slnx b/src/TenantCloud.slnx new file mode 100644 index 0000000..3317364 --- /dev/null +++ b/src/TenantCloud.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Yllibed.TenantCloudClient.Cdp/CdpBrowserDiscovery.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpBrowserDiscovery.cs new file mode 100644 index 0000000..67c1f09 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpBrowserDiscovery.cs @@ -0,0 +1,95 @@ +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..5a4ecf9 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpConnection.cs @@ -0,0 +1,207 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text.Json.Serialization.Metadata; + +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..a157078 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpJsonContext.cs @@ -0,0 +1,17 @@ +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..9af36bf --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpCookie.cs @@ -0,0 +1,19 @@ +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..f94ba2c --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpError.cs @@ -0,0 +1,10 @@ +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..10353dc --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateParams.cs @@ -0,0 +1,10 @@ +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..cbab78c --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpEvaluateResult.cs @@ -0,0 +1,10 @@ +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..1b22724 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpExceptionDetails.cs @@ -0,0 +1,7 @@ +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..d17ce49 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesParams.cs @@ -0,0 +1,7 @@ +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..df25ea3 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpGetCookiesResult.cs @@ -0,0 +1,7 @@ +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..465c948 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRemoteObject.cs @@ -0,0 +1,10 @@ +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..525cd16 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpRequest.cs @@ -0,0 +1,14 @@ +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..706954b --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpResponse.cs @@ -0,0 +1,13 @@ +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..99fb5a5 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/CdpTarget.cs @@ -0,0 +1,19 @@ +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..6624f7d --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshRequest.cs @@ -0,0 +1,13 @@ +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..6388db7 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpMessages/TcRefreshResponse.cs @@ -0,0 +1,19 @@ +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/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.Cdp/CdpTokenProvider.cs b/src/Yllibed.TenantCloudClient.Cdp/CdpTokenProvider.cs new file mode 100644 index 0000000..00e12d0 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/CdpTokenProvider.cs @@ -0,0 +1,464 @@ +using System.Diagnostics; + +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..23c667a --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/FileTokenStore.cs @@ -0,0 +1,55 @@ +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/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 new file mode 100644 index 0000000..d75ab34 --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/JwtHelper.cs @@ -0,0 +1,65 @@ +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..77dbdbf --- /dev/null +++ b/src/Yllibed.TenantCloudClient.Cdp/TcTokenRefresher.cs @@ -0,0 +1,56 @@ +using System.Net.Http.Headers; +using System.Text; + +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/Yllibed.TenantCloudClient.Cdp.csproj b/src/Yllibed.TenantCloudClient.Cdp/Yllibed.TenantCloudClient.Cdp.csproj new file mode 100644 index 0000000..28d2f7b --- /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-$([System.DateTime]::Now.Year) - Carl de Billy - All Rights Reserved + https://github.com/yllibed/TenantCloudClient + https://github.com/yllibed/TenantCloudClient + git + MIT + tenantcloud;cdp + + + + + + + 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..68d81ed 100644 --- a/src/Yllibed.TenantCloudClient.Tests/Given_TcClient.cs +++ b/src/Yllibed.TenantCloudClient.Tests/Given_TcClient.cs @@ -1,193 +1,185 @@ -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 + [TestMethod] + 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(TokenProvider); + var userInfo = await sut.GetUserInfo(CancellationToken.None); + + userInfo.Should().NotBeNull(); + userInfo!.FirstName.Should().NotBeNullOrWhiteSpace(); + userInfo.LastName.Should().NotBeNullOrWhiteSpace(); + userInfo.Id.Should().NotBe(0); + } + + [TestMethod] + public async Task When_GettingAllContacts() + { + var client = new TcClient(TokenProvider); + var sut = client.Contacts; + + 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_GettingMovedInContacts() + { + var client = new TcClient(TokenProvider); + var sut = client.Contacts.OnlyMovedIn(); + + 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_GetProperties() + { + var client = new TcClient(TokenProvider); + var sut = client.Properties; - all.Should().NotBeNull(); - all.Length.Should().NotBe(0); - all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); + 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_GetUnits() + { + var client = new TcClient(TokenProvider); + var sut = client.Units; + + 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_GetTransactionsForTenant() + { + var client = new TcClient(TokenProvider); + var firstContactId = await GetFirstContactId(client); - [TestMethod] - public async Task When_GetTransactionsForUnit() - { - var client = new TcClient(_context); - var firstUnitId = await GetFirstUnitId(client); + var sut = client.Transactions + .ForCategory(TcTransactionCategory.Income) + .ForTenant(firstContactId); + var all = await sut.GetAll(CancellationToken.None); - var sut = client.Transactions + all.Length.Should().NotBe(0); + all.AsEnumerable().Select(x => x.Id).Should().OnlyHaveUniqueItems(); + } + + [TestMethod] + public async Task When_GetTransactionsForUnit() + { + var client = new TcClient(TokenProvider); + 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] + public async Task When_GetExpenseTransactions() + { + var client = new TcClient(TokenProvider); + + 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] + public async Task When_GetBalancePerProperty() + { + var client = new TcClient(TokenProvider); + + 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(); + } + + [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 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) + { + 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; } } diff --git a/src/Yllibed.TenantCloudClient.Tests/TestBase.cs b/src/Yllibed.TenantCloudClient.Tests/TestBase.cs index 4f36dc2..04f06c5 100644 --- a/src/Yllibed.TenantCloudClient.Tests/TestBase.cs +++ b/src/Yllibed.TenantCloudClient.Tests/TestBase.cs @@ -1,11 +1,24 @@ -using Microsoft.Extensions.Configuration; +namespace Yllibed.TenantCloudClient.Tests; -namespace Yllibed.TenantCloudClient.Tests +public class TestBase { - public class TestBase + private static readonly string? s_envToken = + Environment.GetEnvironmentVariable("TC_AUTH_TOKEN"); + + protected ITcAuthTokenProvider TokenProvider { get; private set; } = null!; + + [TestInitialize] + public void EnsureTokenAvailable() { - internal TestBase() + 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.Tests/Yllibed.TenantCloudClient.Tests.csproj b/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj index 430dc06..c468b42 100644 --- a/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj +++ b/src/Yllibed.TenantCloudClient.Tests/Yllibed.TenantCloudClient.Tests.csproj @@ -1,18 +1,13 @@ - + - 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/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/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/JsonAutoLongConverter.cs b/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoLongConverter.cs index 6584481..f4d00a9 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoLongConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoLongConverter.cs @@ -1,52 +1,26 @@ -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..b2ec725 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonAutoNullableLongConverter.cs @@ -0,0 +1,35 @@ +using System.Globalization; + +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..53be671 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonDecimalConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonDecimalConverter.cs @@ -1,23 +1,19 @@ -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..91fcee8 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToDateTimeOffsetConverter.cs @@ -1,33 +1,30 @@ -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..540fb2e 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToNullableDateTimeOffsetConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringDateToNullableDateTimeOffsetConverter.cs @@ -1,37 +1,41 @@ -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..f9607dc 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringToEnumConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonStringToEnumConverter.cs @@ -1,25 +1,17 @@ -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..9c8afcb 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcTransactionStatusConverter.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/JsonTcTransactionStatusConverter.cs @@ -1,32 +1,28 @@ -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/TcContact.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs new file mode 100644 index 0000000..a84d48f --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcContact.cs @@ -0,0 +1,150 @@ +using System.Text.RegularExpressions; + +namespace Yllibed.TenantCloudClient.HttpMessages; + +public partial class TcContact : IHasId +{ + [JsonIgnore] + public long Id { get; set; } + + [JsonPropertyName("email")] + public string Email1 { get; set; } = string.Empty; + + [JsonPropertyName("email_2")] + public string? Email2 { get; set; } + + [JsonPropertyName("email_3")] + public string? Email3 { get; set; } + + public string?[] ValidEmails + { + get + { + IEnumerable GetEmails() + { + 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; + } + } + + return GetEmails().ToArray(); + } + } + + public string Emails => string.Join('|', ValidEmails); + + [JsonPropertyName("phone")] + public string Phone1 { get; set; } = string.Empty; + + [JsonPropertyName("phone_2")] + public string? Phone2 { get; set; } + + [JsonPropertyName("phone_3")] + public string? Phone3 { get; set; } + + public string[] ValidPhones + { + get + { + IEnumerable GetPhones() + { + 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!; + } + } + + return GetPhones().ToArray(); + } + } + + 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; + + [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 bool IsValidEmail(string? s, out string? output) + { + output = null; + + if (string.IsNullOrWhiteSpace(s)) + { + return false; + } + + var match = EmailRegex().Match(s); + + if (match.Success) + { + output = match.Value; + return true; + } + + return false; + } + + [GeneratedRegex( + @"(?:\+)?(?:\d[\-\s\(\)]?){8,15}", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture)] + private static partial Regex PhoneRegex(); + + private static bool IsValidPhone(string? s, out string? output) + { + output = null; + + if (string.IsNullOrWhiteSpace(s)) + { + return false; + } + + var match = PhoneRegex().Match(s); + + if (match.Success) + { + var chars = new List(match.Groups.Count); + foreach (var c in s) + { + if (char.IsDigit(c)) + { + chars.Add(c); + } + } + + output = new string(chars.ToArray()); + + return true; + } + + return false; + } +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcErrorResponse.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcErrorResponse.cs index 5b677a4..b6a46f0 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcErrorResponse.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcErrorResponse.cs @@ -1,10 +1,7 @@ -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/TcJsonApiItem.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiItem.cs new file mode 100644 index 0000000..67917d7 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiItem.cs @@ -0,0 +1,14 @@ +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..368ac22 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiMeta.cs @@ -0,0 +1,7 @@ +namespace Yllibed.TenantCloudClient.HttpMessages; + +internal class TcJsonApiMeta +{ + [JsonPropertyName("pagination")] + public TcJsonApiPagination? Pagination { get; set; } +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiPagination.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiPagination.cs new file mode 100644 index 0000000..f4d78f0 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiPagination.cs @@ -0,0 +1,19 @@ +namespace Yllibed.TenantCloudClient.HttpMessages; + +internal class TcJsonApiPagination +{ + [JsonPropertyName("total")] + public long Total { get; set; } + + [JsonPropertyName("count")] + public long Count { get; set; } + + [JsonPropertyName("per_page")] + public long PerPage { 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..f9c033f --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonApiResponse.cs @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..8fa3559 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcJsonSerializerContext.cs @@ -0,0 +1,16 @@ +namespace Yllibed.TenantCloudClient.HttpMessages; + +[JsonSourceGenerationOptions( + AllowTrailingCommas = true, + PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(TcUserInfoResponse))] +[JsonSerializable(typeof(TcJsonApiResponse))] +[JsonSerializable(typeof(TcJsonApiResponse))] +[JsonSerializable(typeof(TcJsonApiResponse))] +[JsonSerializable(typeof(TcJsonApiResponse))] +[JsonSerializable(typeof(TcJsonApiResponse))] +[JsonSerializable(typeof(TcErrorResponse))] +[JsonSerializable(typeof(TcTokenSet))] +internal partial class TcJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs index 23461c8..da3e002 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcLease.cs @@ -1,79 +1,75 @@ -using System; -using System.Text.Json.Serialization; +namespace Yllibed.TenantCloudClient.HttpMessages; -namespace Yllibed.TenantCloudClient.HttpMessages +public class TcLease : IHasId { - public class TcLease - { - [JsonPropertyName("id")] - public long Id { get; set; } + [JsonIgnore] + 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 deleted file mode 100644 index 6fc1d31..0000000 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponse.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Yllibed.TenantCloudClient.HttpMessages -{ - /// - /// Response used by /v1 api - /// - /// - 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 687e6e0..0000000 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcListResponsePagination.cs +++ /dev/null @@ -1,19 +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/TcLoginRequest.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginRequest.cs deleted file mode 100644 index 309c77a..0000000 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginRequest.cs +++ /dev/null @@ -1,23 +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 946e11f..0000000 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcLoginResponse.cs +++ /dev/null @@ -1,22 +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/HttpMessages/TcPagingListResponse.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListResponse.cs deleted file mode 100644 index 424233e..0000000 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcPagingListResponse.cs +++ /dev/null @@ -1,45 +0,0 @@ -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; } - - } - - 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/TcProperty.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs index 61991b7..ccde902 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcProperty.cs @@ -1,19 +1,23 @@ -using System; -using System.Text.Json.Serialization; +using System.Globalization; -namespace Yllibed.TenantCloudClient.HttpMessages +namespace Yllibed.TenantCloudClient.HttpMessages; + +public class TcProperty : IHasId { - public class TcProperty - { - [JsonPropertyName("id")] - [JsonConverter(typeof(JsonAutoLongConverter))] - public long Id { get; set; } + [JsonIgnore] + public long Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("address1")] + public string? Address1 { get; set; } - public string? Name => Attributes?.Name; + [JsonPropertyName("cityAddress")] + public string? CityAddress { get; set; } - public string Address => $"{Attributes?.Address1} {Attributes?.CityAddress}"; + [JsonPropertyName("property_status")] + public string? Status { get; set; } - [JsonPropertyName("attributes")] - public TcPropertyAttributes? Attributes { get; set; } - } + public string Address => string.Format(CultureInfo.InvariantCulture, "{0} {1}", Address1, CityAddress); } diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcPropertyAttributes.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcPropertyAttributes.cs deleted file mode 100644 index 62633e1..0000000 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcPropertyAttributes.cs +++ /dev/null @@ -1,16 +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/TcTenant.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcTenant.cs index e22758a..fab5226 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcTenant.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcTenant.cs @@ -1,14 +1,11 @@ -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 deleted file mode 100644 index 8693d14..0000000 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcTenantDetails.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; - -namespace Yllibed.TenantCloudClient.HttpMessages -{ - public class TcTenantDetails - { - [JsonPropertyName("id")] - [JsonConverter(typeof(JsonAutoLongConverter))] - public long Id { get; set; } - - [JsonPropertyName("email")] - public string Email1 { get; set; } = string.Empty; - - [JsonPropertyName("email_2")] - public string? Email2 { get; set; } - - [JsonPropertyName("email_3")] - public string? Email3 { get; set; } - - public string?[] ValidEmails - { - get - { - IEnumerable GetEmails() - { - 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; - } - - return GetEmails().ToArray(); - } - } - - public string Emails => string.Join("|", ValidEmails); - - [JsonPropertyName("phone")] - public string Phone1 { get; set; } = string.Empty; - - [JsonPropertyName("phone_2")] - public string? Phone2 { get; set; } - - [JsonPropertyName("phone_3")] - public string? Phone3 { get; set; } - - public string[] ValidPhones - { - get - { - IEnumerable GetPhones() - { - 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!; - } - - return GetPhones().ToArray(); - } - } - - 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; - - [JsonPropertyName("status")] - public TcTenantStatus Status { get; set; } - - - 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 bool IsValidEmail(string? s, out string? output) - { - output = null; - - if (string.IsNullOrWhiteSpace(s)) - { - return false; - } - - var match = EmailRegex.Match(s); - - if (match.Success) - { - output = match.Value; - return true; - } - - return false; - } - - private static readonly Regex PhoneRegex = new Regex( - @"(\+)?(\d[\-\s\(\)]?){8,15}", - RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.CultureInvariant); - - private bool IsValidPhone(string? s, out string? output) - { - output = null; - - if (string.IsNullOrWhiteSpace(s)) - { - return false; - } - - var match = PhoneRegex.Match(s); - - if (match.Success) - { - var chars = new List(match.Groups.Count); - foreach (var c in s) - { - if (char.IsDigit(c)) - { - chars.Add(c); - } - } - - output = new string(chars.ToArray()); - - return true; - } - - return false; - } - } - - public enum TcTenantStatus: byte - { - - } -} 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/TcTransaction.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs index 84769df..12a2595 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcTransaction.cs @@ -1,75 +1,49 @@ -using System; -using System.Text.Json.Serialization; +namespace Yllibed.TenantCloudClient.HttpMessages; -namespace Yllibed.TenantCloudClient.HttpMessages +public class TcTransaction : IHasId { - public class TcTransaction - { - [JsonPropertyName("id")] - [JsonConverter(typeof(JsonAutoLongConverter))] - public long Id { get; set; } + [JsonIgnore] + public long Id { get; set; } - [JsonPropertyName("unit_id")] - [JsonConverter(typeof(JsonAutoNullableLongConverter))] - public long? UnitId { get; set; } + [JsonPropertyName("unit_id")] + [JsonConverter(typeof(JsonAutoNullableLongConverter))] + public long? UnitId { get; set; } - [JsonPropertyName("property_id")] - [JsonConverter(typeof(JsonAutoNullableLongConverter))] - public long? PropertyId { get; set; } + [JsonPropertyName("property_id")] + [JsonConverter(typeof(JsonAutoNullableLongConverter))] + public long? PropertyId { get; set; } - [JsonPropertyName("detail")] - public string? Detail { get; set; } + [JsonPropertyName("detail")] + public string? Detail { get; set; } - [JsonPropertyName("is_recurring")] - public bool IsRecurring { get; set; } + [JsonPropertyName("is_recurring")] + public bool IsRecurring { get; set; } - [JsonPropertyName("created_at")] - public DateTimeOffset CreatedAt { get; set; } + [JsonPropertyName("created_at")] + public DateTimeOffset CreatedAt { get; set; } - [JsonConverter(typeof(JsonStringDateToDateTimeOffsetConverter))] - [JsonPropertyName("date")] - public DateTimeOffset DueDate { get; set; } + [JsonConverter(typeof(JsonStringDateToDateTimeOffsetConverter))] + [JsonPropertyName("date")] + public DateTimeOffset DueDate { get; set; } - [JsonConverter(typeof(JsonDecimalConverter))] - public decimal Amount { get; set; } + [JsonConverter(typeof(JsonDecimalConverter))] + public decimal Amount { get; set; } - [JsonConverter(typeof(JsonDecimalConverter))] - public decimal Paid { get; set; } + [JsonConverter(typeof(JsonDecimalConverter))] + public decimal Paid { get; set; } - [JsonConverter(typeof(JsonDecimalConverter))] - public decimal Balance { get; set; } + [JsonConverter(typeof(JsonDecimalConverter))] + public decimal Balance { get; set; } - public string Currency { get; set; } = string.Empty; + public string Currency { get; set; } = string.Empty; - [JsonConverter(typeof(JsonStringDateToNullableDateTimeOffsetConverter))] - [JsonPropertyName("paid_at")] - public DateTimeOffset? PaidAt { get; set; } + [JsonConverter(typeof(JsonStringDateToNullableDateTimeOffsetConverter))] + [JsonPropertyName("paid_at")] + public DateTimeOffset? PaidAt { get; set; } - [JsonConverter(typeof(JsonStringToEnumConverter))] - public TcTransactionCategory Category { get; set; } + [JsonConverter(typeof(JsonStringToEnumConverter))] + public TcTransactionCategory Category { get; set; } - [JsonConverter(typeof(JsonTcTransactionStatusConverter))] - public TcTransactionStatus Status { get; set; } - } - - public enum TcTransactionCategory : byte - { - Income, - Expense, - Refund, - Credits, - liability - } - - 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/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, +} diff --git a/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs b/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs index 7f29630..b9bd484 100644 --- a/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs +++ b/src/Yllibed.TenantCloudClient/HttpMessages/TcUnit.cs @@ -1,32 +1,28 @@ -using System.Text.Json.Serialization; +namespace Yllibed.TenantCloudClient.HttpMessages; -namespace Yllibed.TenantCloudClient.HttpMessages +public class TcUnit : IHasId { - public class TcUnit - { - [JsonPropertyName("id")] - [JsonConverter(typeof(JsonAutoLongConverter))] - public long Id { get; set; } + [JsonIgnore] + 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; } } 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/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/ITcClient.cs b/src/Yllibed.TenantCloudClient/ITcClient.cs index 5241555..9a07943 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 Contacts { get; } - IPaginatedSource Properties { get; } + IPaginatedSource Properties { get; } - IPaginatedSource Units { get; } + IPaginatedSource Units { get; } - IPaginatedSource Transactions { get; } - } + IPaginatedSource Transactions { get; } + + IPaginatedSource Leases { get; } } diff --git a/src/Yllibed.TenantCloudClient/ITcContext.cs b/src/Yllibed.TenantCloudClient/ITcContext.cs deleted file mode 100644 index cbe4894..0000000 --- a/src/Yllibed.TenantCloudClient/ITcContext.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Net; -using System.Threading; -using System.Threading.Tasks; - -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/ITcTokenStore.cs b/src/Yllibed.TenantCloudClient/ITcTokenStore.cs new file mode 100644 index 0000000..c3f23bd --- /dev/null +++ b/src/Yllibed.TenantCloudClient/ITcTokenStore.cs @@ -0,0 +1,10 @@ +namespace Yllibed.TenantCloudClient; + +/// +/// 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/InMemoryTcContext.cs b/src/Yllibed.TenantCloudClient/InMemoryTcContext.cs deleted file mode 100644 index f178003..0000000 --- a/src/Yllibed.TenantCloudClient/InMemoryTcContext.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Net; -using System.Threading; -using System.Threading.Tasks; - -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); - 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) - { - return Task.FromResult(_token); - } - } -} 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/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/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/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/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"; +} 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; + } +} diff --git a/src/Yllibed.TenantCloudClient/TcClient.cs b/src/Yllibed.TenantCloudClient/TcClient.cs index c5266ce..6fee874 100644 --- a/src/Yllibed.TenantCloudClient/TcClient.cs +++ b/src/Yllibed.TenantCloudClient/TcClient.cs @@ -1,178 +1,157 @@ -using System; +using System.Globalization; 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 Yllibed.TenantCloudClient.HttpMessages; +using System.Text.Json.Serialization.Metadata; -namespace Yllibed.TenantCloudClient -{ - 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) - { - _context = context; - - Tenants = new PaginatedSource(GetTenantPage, ""); - - Properties = new PaginatedSource(GetPropertyPage, ""); +namespace Yllibed.TenantCloudClient; - Units = new PaginatedSource(GetUnitsPage, ""); - - Transactions = new PaginatedSource(GetTransactionsPage, ""); - - var httpHandler = new HttpClientHandler() - { - UseCookies = false, - UseDefaultCredentials = false, - AllowAutoRedirect = true, - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, - }; +public class TcClient : IDisposable, ITcClient +{ + private readonly ITcAuthTokenProvider _tokenProvider; + private readonly HttpClient _httpClient; - _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 TcClient(ITcAuthTokenProvider tokenProvider) + { + _tokenProvider = tokenProvider; - public async Task GetUserInfo(CancellationToken ct) - { - var result = await HttpGet(ct, "v1/auth/user"); - return result?.User; - } + Contacts = new PaginatedSource( + (ct, page, extra) => GetJsonApiPage(ct, "contacts", page, extra, + TcJsonSerializerContext.Default.TcJsonApiResponseTcContact), ""); + Properties = new PaginatedSource( + (ct, page, extra) => GetJsonApiPage(ct, "properties", page, extra, + TcJsonSerializerContext.Default.TcJsonApiResponseTcProperty), ""); - public IPaginatedSource Tenants { get; } + Units = new PaginatedSource( + (ct, page, extra) => GetJsonApiPage(ct, "units", page, extra, + TcJsonSerializerContext.Default.TcJsonApiResponseTcUnit), ""); - 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); - } + Transactions = new PaginatedSource( + (ct, page, extra) => GetJsonApiPage(ct, "transactions", page, extra, + TcJsonSerializerContext.Default.TcJsonApiResponseTcTransaction), ""); - public IPaginatedSource Properties { get; } + Leases = new PaginatedSource( + (ct, page, extra) => GetJsonApiPage(ct, "leases", page, extra, + TcJsonSerializerContext.Default.TcJsonApiResponseTcLease), ""); - private async Task<(ReadOnlyMemory, long, long)> GetPropertyPage(CancellationToken ct, long pageNo, string extraUrl) + var httpHandler = new HttpClientHandler() { - 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 Units { get; } + UseCookies = false, + UseDefaultCredentials = false, + AllowAutoRedirect = true, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + }; - private async Task<(ReadOnlyMemory, long, long)> GetUnitsPage(CancellationToken ct, long pageNo, string extraUrl) + _httpClient = new HttpClient(httpHandler, true) { - var response = await HttpGet>(ct, "v1/landlord/units?page=" + pageNo + extraUrl); - var memory = new Memory(response.Entries); - return (memory, pageNo, response?.Pagination?.Total ?? 0); - } + BaseAddress = new Uri("https://api.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 Transactions { get; } + public async Task GetUserInfo(CancellationToken ct) + { + var result = await HttpGet(ct, "auth/user", TcJsonSerializerContext.Default.TcUserInfoResponse).ConfigureAwait(false); + return result?.User; + } - private async Task<(ReadOnlyMemory, long, long)> GetTransactionsPage(CancellationToken ct, long pageNo, string extraUrl) - { - var response = await HttpGet>(ct, "v1/landlord/transactions?page=" + pageNo + extraUrl); - var memory = new Memory(response.Entries); - return (memory, pageNo, response?.Pagination?.Total ?? 0); - } + public IPaginatedSource Contacts { get; } - private static readonly JsonSerializerOptions _jsonOptions = - new JsonSerializerOptions - { - AllowTrailingCommas = true, - PropertyNameCaseInsensitive = true - }; + public IPaginatedSource Properties { get; } - private async Task HttpGet(CancellationToken ct, string uri) - { - var req = new HttpRequestMessage(HttpMethod.Get, uri); - using var response = await HttpSend(ct, req); - await using var stream = await response.Content.ReadAsStreamAsync(); + public IPaginatedSource Units { get; } - if (response.IsSuccessStatusCode) - { - var payload = await JsonSerializer.DeserializeAsync(stream, _jsonOptions, ct); - return payload; - } - else - { - var errorPayload = await JsonSerializer.DeserializeAsync(stream, _jsonOptions, ct); - throw new TcClientException(response.StatusCode, errorPayload?.Message ?? "Http error"); + public IPaginatedSource Transactions { get; } - } - } + public IPaginatedSource Leases { get; } - private async Task HttpSend(CancellationToken ct, HttpRequestMessage request) - { - var token = await _context.GetAuthToken(ct); + private async Task<(ReadOnlyMemory, long, long)> GetJsonApiPage( + CancellationToken ct, string endpoint, long pageNo, string extraUrl, + JsonTypeInfo> typeInfo) + where T : class, IHasId + { + var url = endpoint + "?page=" + pageNo.ToString(CultureInfo.InvariantCulture) + extraUrl; + var response = await HttpGet(ct, url, typeInfo).ConfigureAwait(false); - if (!string.IsNullOrEmpty(token)) + var entries = response.Data? + .Select(item => { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); - - if (response.StatusCode != HttpStatusCode.Unauthorized) + var attr = item.Attributes; + if (attr is not null) { - return response; + attr.Id = item.Id; } - request.Headers.Authorization = null; - } - - var loginRequest = new TcLoginRequest(await _context.GetCredentials(ct)); - var loginRequestMsg = new HttpRequestMessage(HttpMethod.Post, "v1/auth/login") - { - Content = GetJsonContent(loginRequest) - }; + return attr!; + }) + .Where(a => a is not null) + .ToArray() ?? Array.Empty(); - var loginResponse = await _httpClient.SendAsync(loginRequestMsg, HttpCompletionOption.ResponseHeadersRead, ct); + return (entries.AsMemory(), pageNo, response.Meta?.Pagination?.Total ?? 0); + } - if (!loginResponse.IsSuccessStatusCode) - { - throw new TcClientException(loginResponse.StatusCode, "Unable to login"); - } + 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); - await using var loginResponseStream = await loginResponse.Content.ReadAsStreamAsync(); - var loginResponsePayload = await JsonSerializer.DeserializeAsync(loginResponseStream, _jsonOptions, ct); + if (response.IsSuccessStatusCode) + { + var payload = await JsonSerializer.DeserializeAsync(stream, typeInfo, ct).ConfigureAwait(false); + return payload ?? throw new TcClientException(response.StatusCode, "Null response payload"); + } + else + { + var errorPayload = await JsonSerializer.DeserializeAsync(stream, TcJsonSerializerContext.Default.TcErrorResponse, ct).ConfigureAwait(false); + throw new TcClientException(response.StatusCode, errorPayload?.Message ?? "Http error"); + } + } - if ((token = loginResponsePayload?.AccessToken) == null) - { - throw new TcClientException(loginResponse.StatusCode, "Invalid login response"); - } - else - { - await _context.SetAuthToken(ct, token); + private async Task HttpSend(CancellationToken ct, HttpRequestMessage request) + { + var token = await _tokenProvider.GetToken(ct).ConfigureAwait(false); - request.Headers.Authorization = new AuthenticationHeaderValue(loginResponsePayload?.TokenType ?? "Bearer", token); - return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); - } + if (string.IsNullOrEmpty(token)) + { + throw new TcClientException(HttpStatusCode.Unauthorized, "No auth token available"); } - private static HttpContent GetJsonContent(object entity) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + + if (response.StatusCode != HttpStatusCode.Unauthorized) { - var payload = JsonSerializer.Serialize(entity); - return new StringContent(payload, _encoding, "application/json"); + return response; } - public void Dispose() + // 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 (string.IsNullOrEmpty(newToken) || string.Equals(newToken, token, StringComparison.Ordinal)) { - _httpClient.Dispose(); + throw new TcClientException(HttpStatusCode.Unauthorized, "Auth token rejected and no new token available"); } + + // 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); + + return await _httpClient.SendAsync(retryRequest, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + } + + 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/TcContactsPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcContactsPaginatedSourceExtensions.cs new file mode 100644 index 0000000..c5b0199 --- /dev/null +++ b/src/Yllibed.TenantCloudClient/TcContactsPaginatedSourceExtensions.cs @@ -0,0 +1,44 @@ +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..13a59da --- /dev/null +++ b/src/Yllibed.TenantCloudClient/TcLeasesPaginatedSourceExtensions.cs @@ -0,0 +1,36 @@ +using System.Globalization; + +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 8849707..0000000 --- a/src/Yllibed.TenantCloudClient/TcTenantsPaginatedSourceExtensions.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -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."); - } - - public static IPaginatedSource OnlyArchived(this IPaginatedSource source) - { - if (source is PaginatedSource paginatedSource) - { - return paginatedSource.ProjectedWithExtraUrl(url => url + "&display=archived"); - } - - 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."); - } - } -} diff --git a/src/Yllibed.TenantCloudClient/TcTokenSet.cs b/src/Yllibed.TenantCloudClient/TcTokenSet.cs new file mode 100644 index 0000000..8bef30f --- /dev/null +++ b/src/Yllibed.TenantCloudClient/TcTokenSet.cs @@ -0,0 +1,9 @@ +namespace Yllibed.TenantCloudClient; + +/// +/// 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/TcTransactionsPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs index 8a99aa6..defe047 100644 --- a/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs +++ b/src/Yllibed.TenantCloudClient/TcTransactionsPaginatedSourceExtensions.cs @@ -1,82 +1,88 @@ -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 + "&filter[client_id]=" + 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 + "&filter[property_id][]=" + 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 + "&filter[unit_id]=" + 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 + "&filter[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 + "&filter[category][]=" + category.ToString().ToLowerInvariant()); } - internal static string ToSerializedString(this TcTransactionStatus status) + throw new ArgumentException("Invalid source.", nameof(source)); + } + + public static IPaginatedSource SortByDateDescending(this IPaginatedSource source) + { + if (source is PaginatedSource paginatedSource) { - 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"); - } + return paginatedSource.ProjectedWithExtraUrl(url => url + "&sort=-date,-id"); } + throw new ArgumentException("Invalid source.", nameof(source)); + } + + internal static string ToSerializedString(this TcTransactionStatus 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"); + } } } diff --git a/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs b/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs index b10ccdf..4d4eb83 100644 --- a/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs +++ b/src/Yllibed.TenantCloudClient/TcUnitsPaginatedSourceExtensions.cs @@ -1,39 +1,36 @@ -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 + "&filter[is_rented]=true"); } - 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 + "&filter[is_rented]=false"); } - 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 + "&filter[property_id][]=" + propertyId.ToString(NumberFormatInfo.InvariantInfo)); } + + throw new ArgumentException("Invalid source.", nameof(source)); } } 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 c4353d2..a31ab2c 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 @@ -16,20 +17,24 @@ ** 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 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" + } +}