From 9f61e31043a99c3a5612a8048afdb52920381dfd Mon Sep 17 00:00:00 2001 From: Adam Reed <16042069+iamadamreed@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:27:32 -0500 Subject: [PATCH 1/7] Replace Linear config with GitHub work tracking in CLAUDE.md --- CLAUDE.md | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cb3413c..eb380bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,22 +2,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Linear Configuration +## Work Tracking + +Work is tracked in GitHub (issues and pull requests) on [iamadamreed/Unifi.NET](https://github.com/iamadamreed/Unifi.NET). Use the `gh` CLI for GitHub operations. ```yaml -LINEAR_PROJECT_ID: "62b16559-5a98-4a38-8980-bcfc96cc72f1" -LINEAR_PROJECT_NAME: "Unifi.NET" -LINEAR_TEAM_ID: "7fe71b21-cd2a-4776-98ef-8814b2921b8c" -LINEAR_TEAM_NAME: "Spire Recovery Solutions" - -# Status IDs -LINEAR_STATUS_ON_DECK: "2ec690d5-e27e-45dc-bf26-9201f3b2e276" -LINEAR_STATUS_IN_PROGRESS: "d171b2d5-87f8-4c4e-b7d0-a13dd5bc6689" -LINEAR_STATUS_BLOCKED: "8a88d533-e078-425a-b1f1-ad93cad81ef2" -LINEAR_STATUS_IN_REVIEW: "6f1d6fc0-4e3c-42da-903a-85fcc3ea83e9" -LINEAR_STATUS_DONE: "cf75dc3e-2542-4918-b2da-980fac469533" - -# Branch Configuration DEFAULT_BRANCH: "main" ``` From 2299c8cdf1f98c5ac2317ffd010d868ee21d867f Mon Sep 17 00:00:00 2001 From: Adam Reed <16042069+iamadamreed@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:35:31 -0500 Subject: [PATCH 2/7] Migrate solution to .NET 10 - TargetFramework net9.0 -> net10.0 across all projects - global.json SDK 9.0.100 -> 10.0.100 - Microsoft.* packages 9.0.0 -> 10.0.0, Test.Sdk 17.14.1, NetAnalyzers 10.0.0 - Drop explicit System.Text.Json reference (framework-provided, NU1510) - Pin Microsoft.OpenApi 2.11.0 (transitive 2.0.0 has GHSA-v5pm-xwqc-g5wc) - Update CI workflow and cross-build Dockerfile to .NET 10 --- .github/workflows/publish.yml | 2 +- Directory.Build.props | 2 +- Directory.Packages.props | 40 +++++++++---------- Dockerfile.windows-cross | 2 +- .../Unifi.NET.Access.Tests.csproj | 2 +- Unifi.NET.Access/Unifi.NET.Access.csproj | 1 - Unifi.NET.AccessViewer/Directory.Build.props | 2 +- .../Unifi.NET.AccessViewer.Client.csproj | 2 +- .../Unifi.NET.AccessViewer.Server.csproj | 4 +- .../Unifi.NET.AccessViewer.Shared.csproj | 2 +- .../Unifi.NET.Common.Tests.csproj | 2 +- Unifi.NET.Common/Unifi.NET.Common.csproj | 1 - Unifi.NET.Network/Unifi.NET.Network.csproj | 2 +- Unifi.NET.Protect/Unifi.NET.Protect.csproj | 2 +- Unifi.NET.Samples/Unifi.NET.Samples.csproj | 2 +- .../Unifi.NET.SiteManager.csproj | 2 +- global.json | 2 +- 17 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f9c729b..32cea9c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,7 +23,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: '9.x' + dotnet-version: '10.x' - name: Set Branch-Specific Variables run: | diff --git a/Directory.Build.props b/Directory.Build.props index 415a25c..fcb17da 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - net9.0 + net10.0 latest enable enable diff --git a/Directory.Packages.props b/Directory.Packages.props index 3305adb..b898974 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,27 +6,27 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - + + + + + - + @@ -34,6 +34,6 @@ - + \ No newline at end of file diff --git a/Dockerfile.windows-cross b/Dockerfile.windows-cross index f5d1247..47f9dd6 100644 --- a/Dockerfile.windows-cross +++ b/Dockerfile.windows-cross @@ -1,5 +1,5 @@ # Use Linux .NET SDK to cross-compile for Windows -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src diff --git a/Unifi.NET.Access.Tests/Unifi.NET.Access.Tests.csproj b/Unifi.NET.Access.Tests/Unifi.NET.Access.Tests.csproj index 371b6ff..0122f96 100644 --- a/Unifi.NET.Access.Tests/Unifi.NET.Access.Tests.csproj +++ b/Unifi.NET.Access.Tests/Unifi.NET.Access.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable false diff --git a/Unifi.NET.Access/Unifi.NET.Access.csproj b/Unifi.NET.Access/Unifi.NET.Access.csproj index 6fc5e45..693e154 100644 --- a/Unifi.NET.Access/Unifi.NET.Access.csproj +++ b/Unifi.NET.Access/Unifi.NET.Access.csproj @@ -14,7 +14,6 @@ - diff --git a/Unifi.NET.AccessViewer/Directory.Build.props b/Unifi.NET.AccessViewer/Directory.Build.props index 2e660e8..231095f 100644 --- a/Unifi.NET.AccessViewer/Directory.Build.props +++ b/Unifi.NET.AccessViewer/Directory.Build.props @@ -1,7 +1,7 @@ - false + false false true diff --git a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Client/Unifi.NET.AccessViewer.Client.csproj b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Client/Unifi.NET.AccessViewer.Client.csproj index 13347cd..9e0b15e 100644 --- a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Client/Unifi.NET.AccessViewer.Client.csproj +++ b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Client/Unifi.NET.AccessViewer.Client.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable diff --git a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Unifi.NET.AccessViewer.Server.csproj b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Unifi.NET.AccessViewer.Server.csproj index eb5ac8d..48ce937 100644 --- a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Unifi.NET.AccessViewer.Server.csproj +++ b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Unifi.NET.AccessViewer.Server.csproj @@ -1,13 +1,15 @@ - net9.0 + net10.0 enable enable + + diff --git a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Shared/Unifi.NET.AccessViewer.Shared.csproj b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Shared/Unifi.NET.AccessViewer.Shared.csproj index 125f4c9..b760144 100644 --- a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Shared/Unifi.NET.AccessViewer.Shared.csproj +++ b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Shared/Unifi.NET.AccessViewer.Shared.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable diff --git a/Unifi.NET.Common.Tests/Unifi.NET.Common.Tests.csproj b/Unifi.NET.Common.Tests/Unifi.NET.Common.Tests.csproj index e8d1d44..9868570 100644 --- a/Unifi.NET.Common.Tests/Unifi.NET.Common.Tests.csproj +++ b/Unifi.NET.Common.Tests/Unifi.NET.Common.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable false diff --git a/Unifi.NET.Common/Unifi.NET.Common.csproj b/Unifi.NET.Common/Unifi.NET.Common.csproj index 2959523..6a4293a 100644 --- a/Unifi.NET.Common/Unifi.NET.Common.csproj +++ b/Unifi.NET.Common/Unifi.NET.Common.csproj @@ -9,7 +9,6 @@ - diff --git a/Unifi.NET.Network/Unifi.NET.Network.csproj b/Unifi.NET.Network/Unifi.NET.Network.csproj index 125f4c9..b760144 100644 --- a/Unifi.NET.Network/Unifi.NET.Network.csproj +++ b/Unifi.NET.Network/Unifi.NET.Network.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable diff --git a/Unifi.NET.Protect/Unifi.NET.Protect.csproj b/Unifi.NET.Protect/Unifi.NET.Protect.csproj index 125f4c9..b760144 100644 --- a/Unifi.NET.Protect/Unifi.NET.Protect.csproj +++ b/Unifi.NET.Protect/Unifi.NET.Protect.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable diff --git a/Unifi.NET.Samples/Unifi.NET.Samples.csproj b/Unifi.NET.Samples/Unifi.NET.Samples.csproj index 0fc4dd6..ade972f 100644 --- a/Unifi.NET.Samples/Unifi.NET.Samples.csproj +++ b/Unifi.NET.Samples/Unifi.NET.Samples.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable true diff --git a/Unifi.NET.SiteManager/Unifi.NET.SiteManager.csproj b/Unifi.NET.SiteManager/Unifi.NET.SiteManager.csproj index 125f4c9..b760144 100644 --- a/Unifi.NET.SiteManager/Unifi.NET.SiteManager.csproj +++ b/Unifi.NET.SiteManager/Unifi.NET.SiteManager.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable diff --git a/global.json b/global.json index d5bf446..f72210c 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.100", + "version": "10.0.100", "rollForward": "latestFeature" } } \ No newline at end of file From a70b6ceaf4856fd601d519c415c7f5a96ccce1b5 Mon Sep 17 00:00:00 2001 From: Adam Reed <16042069+iamadamreed@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:45:51 -0500 Subject: [PATCH 3/7] Modernize Access SDK for .NET 10 - System.Threading.Lock for the combined-resolver lazy init - Seal internal JsonSerializerContext classes - Collection expressions for empty-list/array returns --- .../Contexts/AccessPolicyJsonContext.cs | 2 +- .../Serialization/Contexts/CoreJsonContext.cs | 2 +- .../Serialization/Contexts/CredentialJsonContext.cs | 2 +- .../Serialization/Contexts/DeviceJsonContext.cs | 2 +- .../Serialization/Contexts/DoorJsonContext.cs | 2 +- .../Serialization/Contexts/SystemLogJsonContext.cs | 2 +- .../Serialization/Contexts/UserGroupJsonContext.cs | 2 +- .../Serialization/Contexts/UserJsonContext.cs | 2 +- .../Serialization/UnifiAccessJsonContext.cs | 2 +- Unifi.NET.Access/Services/AccessPolicyService.cs | 2 +- Unifi.NET.Access/Services/CredentialService.cs | 6 +++--- Unifi.NET.Access/Services/DeviceService.cs | 4 ++-- Unifi.NET.Access/Services/DoorService.cs | 2 +- Unifi.NET.Access/Services/SystemLogService.cs | 2 +- Unifi.NET.Access/Services/UserGroupService.cs | 12 ++++++------ Unifi.NET.Access/Services/UserService.cs | 10 +++++----- 16 files changed, 28 insertions(+), 28 deletions(-) diff --git a/Unifi.NET.Access/Serialization/Contexts/AccessPolicyJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/AccessPolicyJsonContext.cs index 95f0085..b8df053 100644 --- a/Unifi.NET.Access/Serialization/Contexts/AccessPolicyJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/AccessPolicyJsonContext.cs @@ -20,6 +20,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; // API wrapper types [JsonSerializable(typeof(UnifiApiResponse))] [JsonSerializable(typeof(UnifiApiResponse>))] -internal partial class AccessPolicyJsonContext : JsonSerializerContext +internal sealed partial class AccessPolicyJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/Contexts/CoreJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/CoreJsonContext.cs index 8c6d52d..0b3e316 100644 --- a/Unifi.NET.Access/Serialization/Contexts/CoreJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/CoreJsonContext.cs @@ -16,6 +16,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(PaginationInfo))] -internal partial class CoreJsonContext : JsonSerializerContext +internal sealed partial class CoreJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/Contexts/CredentialJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/CredentialJsonContext.cs index f78f33d..6c882b8 100644 --- a/Unifi.NET.Access/Serialization/Contexts/CredentialJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/CredentialJsonContext.cs @@ -34,6 +34,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; [JsonSerializable(typeof(UnifiApiResponse))] [JsonSerializable(typeof(UnifiApiResponse>))] [JsonSerializable(typeof(UnifiApiResponse>))] -internal partial class CredentialJsonContext : JsonSerializerContext +internal sealed partial class CredentialJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/Contexts/DeviceJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/DeviceJsonContext.cs index 1cf1140..31c7a72 100644 --- a/Unifi.NET.Access/Serialization/Contexts/DeviceJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/DeviceJsonContext.cs @@ -18,6 +18,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; [JsonSerializable(typeof(List>))] // API wrapper types [JsonSerializable(typeof(UnifiApiResponse>>))] -internal partial class DeviceJsonContext : JsonSerializerContext +internal sealed partial class DeviceJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/Contexts/DoorJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/DoorJsonContext.cs index f913435..9c59ca7 100644 --- a/Unifi.NET.Access/Serialization/Contexts/DoorJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/DoorJsonContext.cs @@ -24,6 +24,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; [JsonSerializable(typeof(UnifiApiResponse>))] [JsonSerializable(typeof(UnifiApiResponse))] [JsonSerializable(typeof(UnifiApiResponse))] -internal partial class DoorJsonContext : JsonSerializerContext +internal sealed partial class DoorJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/Contexts/SystemLogJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/SystemLogJsonContext.cs index ad866a3..5260f35 100644 --- a/Unifi.NET.Access/Serialization/Contexts/SystemLogJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/SystemLogJsonContext.cs @@ -25,6 +25,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(UnifiApiResponse))] [JsonSerializable(typeof(UnifiApiResponse))] -internal partial class SystemLogJsonContext : JsonSerializerContext +internal sealed partial class SystemLogJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/Contexts/UserGroupJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/UserGroupJsonContext.cs index 99b9ff2..e6022f8 100644 --- a/Unifi.NET.Access/Serialization/Contexts/UserGroupJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/UserGroupJsonContext.cs @@ -26,6 +26,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; [JsonSerializable(typeof(UnifiApiResponse))] [JsonSerializable(typeof(UnifiApiResponse>))] [JsonSerializable(typeof(UnifiApiResponse>))] -internal partial class UserGroupJsonContext : JsonSerializerContext +internal sealed partial class UserGroupJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/Contexts/UserJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/UserJsonContext.cs index 40b489a..cb3cbec 100644 --- a/Unifi.NET.Access/Serialization/Contexts/UserJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/UserJsonContext.cs @@ -24,6 +24,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; // API wrapper types [JsonSerializable(typeof(UnifiApiResponse))] [JsonSerializable(typeof(UnifiApiResponse>))] -internal partial class UserJsonContext : JsonSerializerContext +internal sealed partial class UserJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/UnifiAccessJsonContext.cs b/Unifi.NET.Access/Serialization/UnifiAccessJsonContext.cs index 27c316b..3be1f7a 100644 --- a/Unifi.NET.Access/Serialization/UnifiAccessJsonContext.cs +++ b/Unifi.NET.Access/Serialization/UnifiAccessJsonContext.cs @@ -12,7 +12,7 @@ namespace Unifi.NET.Access.Serialization; public static class UnifiAccessJsonContext { private static IJsonTypeInfoResolver? _combined; - private static readonly object _lock = new(); + private static readonly Lock _lock = new(); /// /// Gets the combined type info resolver for all UniFi Access types. diff --git a/Unifi.NET.Access/Services/AccessPolicyService.cs b/Unifi.NET.Access/Services/AccessPolicyService.cs index e724bf6..f96622f 100644 --- a/Unifi.NET.Access/Services/AccessPolicyService.cs +++ b/Unifi.NET.Access/Services/AccessPolicyService.cs @@ -51,6 +51,6 @@ public async Task GetAccessPolicyAsync(string policyId, Ca public async Task> GetAccessPoliciesAsync(CancellationToken cancellationToken = default) { var policies = await GetAsync>("/api/v1/developer/access_policies", cancellationToken); - return policies ?? new List(); + return policies ?? []; } } \ No newline at end of file diff --git a/Unifi.NET.Access/Services/CredentialService.cs b/Unifi.NET.Access/Services/CredentialService.cs index 4fd34e6..19b7608 100644 --- a/Unifi.NET.Access/Services/CredentialService.cs +++ b/Unifi.NET.Access/Services/CredentialService.cs @@ -84,7 +84,7 @@ public async Task>> GetNfcCardsAsync(int { return new PaginatedResponse> { - Items = new List(), + Items = [], Page = pageNum ?? 1, PageSize = pageSize ?? 25, Total = 0 @@ -108,7 +108,7 @@ public async Task>> GetNfcCardsAsync(int return new PaginatedResponse> { - Items = apiResponse.Data ?? new List(), + Items = apiResponse.Data ?? [], Page = apiResponse.Pagination?.PageNum ?? pageNum ?? 1, PageSize = apiResponse.Pagination?.PageSize ?? pageSize ?? 25, Total = apiResponse.Pagination?.Total ?? 0 @@ -157,6 +157,6 @@ public async Task> ImportNfcCardsAsync(Impor var jsonTypeInfo = (JsonTypeInfo>>)_jsonOptions.GetTypeInfo(typeof(UnifiApiResponse>)); var apiResponse = JsonSerializer.Deserialize(response.Content ?? "{}", jsonTypeInfo); - return apiResponse?.Data ?? new List(); + return apiResponse?.Data ?? []; } } \ No newline at end of file diff --git a/Unifi.NET.Access/Services/DeviceService.cs b/Unifi.NET.Access/Services/DeviceService.cs index 16accfe..4aada1a 100644 --- a/Unifi.NET.Access/Services/DeviceService.cs +++ b/Unifi.NET.Access/Services/DeviceService.cs @@ -28,7 +28,7 @@ public async Task> GetDevicesAsync(CancellationToken { return devices.SelectMany(list => list ?? new List()); } - - return new List(); + + return []; } } \ No newline at end of file diff --git a/Unifi.NET.Access/Services/DoorService.cs b/Unifi.NET.Access/Services/DoorService.cs index f8ea2ab..52a4dc0 100644 --- a/Unifi.NET.Access/Services/DoorService.cs +++ b/Unifi.NET.Access/Services/DoorService.cs @@ -28,7 +28,7 @@ public async Task GetDoorAsync(string doorId, CancellationToken ca public async Task> GetDoorsAsync(CancellationToken cancellationToken = default) { var doors = await GetAsync>("/api/v1/developer/doors", cancellationToken); - return doors ?? new List(); + return doors ?? []; } /// diff --git a/Unifi.NET.Access/Services/SystemLogService.cs b/Unifi.NET.Access/Services/SystemLogService.cs index 99a51c8..4f0ab7a 100644 --- a/Unifi.NET.Access/Services/SystemLogService.cs +++ b/Unifi.NET.Access/Services/SystemLogService.cs @@ -122,7 +122,7 @@ public async Task ExportSystemLogsAsync( throw new UnifiAccessException($"Failed to export system logs: {response.StatusCode}", "CODE_SYSTEM_ERROR", (int?)response.StatusCode); } - return response.RawBytes ?? Array.Empty(); + return response.RawBytes ?? []; } /// diff --git a/Unifi.NET.Access/Services/UserGroupService.cs b/Unifi.NET.Access/Services/UserGroupService.cs index af634d6..35bf409 100644 --- a/Unifi.NET.Access/Services/UserGroupService.cs +++ b/Unifi.NET.Access/Services/UserGroupService.cs @@ -63,7 +63,7 @@ public async Task>> GetUserGroupsAsync { return new PaginatedResponse> { - Items = new List(), + Items = [], Page = pageNum ?? 1, PageSize = pageSize ?? 25, Total = 0 @@ -87,7 +87,7 @@ public async Task>> GetUserGroupsAsync return new PaginatedResponse> { - Items = apiResponse.Data ?? new List(), + Items = apiResponse.Data ?? [], Page = apiResponse.Pagination?.PageNum ?? pageNum ?? 1, PageSize = apiResponse.Pagination?.PageSize ?? pageSize ?? 25, Total = apiResponse.Pagination?.Total ?? 0 @@ -176,7 +176,7 @@ public async Task>> GetUsersInGroupAsync(st { return new PaginatedResponse> { - Items = new List(), + Items = [], Page = pageNum ?? 1, PageSize = pageSize ?? 25, Total = 0 @@ -200,7 +200,7 @@ public async Task>> GetUsersInGroupAsync(st return new PaginatedResponse> { - Items = apiResponse.Data ?? new List(), + Items = apiResponse.Data ?? [], Page = apiResponse.Pagination?.PageNum ?? pageNum ?? 1, PageSize = apiResponse.Pagination?.PageSize ?? pageSize ?? 25, Total = apiResponse.Pagination?.Total ?? 0 @@ -212,7 +212,7 @@ public async Task> SearchUserGroupsAsync(string k { ArgumentException.ThrowIfNullOrEmpty(keyword); var groups = await GetAsync>($"/api/v1/developer/user_groups/search?keyword={Uri.EscapeDataString(keyword)}", cancellationToken); - return groups ?? new List(); + return groups ?? []; } /// @@ -235,6 +235,6 @@ public async Task> ImportUserGroupsAsync(I var jsonTypeInfo = (JsonTypeInfo>>)_jsonOptions.GetTypeInfo(typeof(UnifiApiResponse>)); var apiResponse = JsonSerializer.Deserialize(response.Content ?? "{}", jsonTypeInfo); - return apiResponse?.Data ?? new List(); + return apiResponse?.Data ?? []; } } \ No newline at end of file diff --git a/Unifi.NET.Access/Services/UserService.cs b/Unifi.NET.Access/Services/UserService.cs index b60ca13..d53918d 100644 --- a/Unifi.NET.Access/Services/UserService.cs +++ b/Unifi.NET.Access/Services/UserService.cs @@ -62,7 +62,7 @@ public async Task> GetUsersAsync(CancellationToken can if (!countResponse.IsSuccessful || string.IsNullOrEmpty(countResponse.Content)) { - return new List(); + return []; } // Parse to get total count @@ -73,7 +73,7 @@ public async Task> GetUsersAsync(CancellationToken can { // Fallback to old method if pagination is not available var users = await GetAsync>("/api/v1/developer/users?expand[]=access_policy", cancellationToken); - return users ?? new List(); + return users ?? []; } var total = paginatedResponse.Pagination.Total; @@ -132,7 +132,7 @@ public async Task>> GetUsersAsync(int pageN { return new PaginatedResponse> { - Items = new List(), + Items = [], Page = pageNum, PageSize = pageSize, Total = 0 @@ -156,7 +156,7 @@ public async Task>> GetUsersAsync(int pageN return new PaginatedResponse> { - Items = apiResponse.Data ?? new List(), + Items = apiResponse.Data ?? [], Page = apiResponse.Pagination?.PageNum ?? pageNum, PageSize = apiResponse.Pagination?.PageSize ?? pageSize, Total = apiResponse.Pagination?.Total ?? 0 @@ -215,7 +215,7 @@ public async Task> GetUserAccessPoliciesAsync( var user = await GetUserAsync(userId, cancellationToken); if (user.AccessPolicies == null) { - return new List(); + return []; } // Convert AccessPolicyInfo to AccessPolicyResponse From d99cfc9917906caab899c36c8138432e5fa0c004 Mon Sep 17 00:00:00 2001 From: Adam Reed <16042069+iamadamreed@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:45:51 -0500 Subject: [PATCH 4/7] Modernize AccessViewer minimal APIs - Group endpoints under /api route group - TypedResults with explicit Results<...> signatures for accurate OpenAPI schemas --- .../Unifi.NET.AccessViewer.Server/Program.cs | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Program.cs b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Program.cs index fbaf526..d7e4a7e 100644 --- a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Program.cs +++ b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Http.HttpResults; using Unifi.NET.Access; using Unifi.NET.Access.Configuration; using Unifi.NET.Access.Models.SystemLogs; @@ -55,11 +56,13 @@ app.UseHttpsRedirection(); // Map API endpoints -app.MapGet("/api/users/search", async (string? employeeNumber, IUnifiAccessClient client) => +var api = app.MapGroup("/api"); + +api.MapGet("/users/search", async Task, BadRequest, NotFound, ProblemHttpResult>> (string? employeeNumber, IUnifiAccessClient client) => { if (string.IsNullOrWhiteSpace(employeeNumber)) { - return Results.BadRequest("Employee number is required"); + return TypedResults.BadRequest("Employee number is required"); } try @@ -67,13 +70,13 @@ // Use search API for much faster response var users = await client.Users.SearchUsersAsync(employeeNumber); var user = users.FirstOrDefault(u => u.EmployeeNumber == employeeNumber); - + if (user == null) { - return Results.NotFound($"No user found with employee number: {employeeNumber}"); + return TypedResults.NotFound($"No user found with employee number: {employeeNumber}"); } - return Results.Ok(new UserSearchResult + return TypedResults.Ok(new UserSearchResult { Id = user.Id, FirstName = user.FirstName, @@ -85,12 +88,12 @@ } catch (Exception ex) { - return Results.Problem($"Error searching users: {ex.Message}"); + return TypedResults.Problem($"Error searching users: {ex.Message}"); } }) .WithName("SearchUser"); -app.MapPost("/api/access-logs", async (AccessLogRequest request, IUnifiAccessClient client) => +api.MapPost("/access-logs", async Task, ProblemHttpResult>> (AccessLogRequest request, IUnifiAccessClient client) => { try { @@ -103,8 +106,8 @@ }; var response = await client.SystemLogs.GetSystemLogsAsync( - logRequest, - request.PageNum ?? 1, + logRequest, + request.PageNum ?? 1, request.PageSize ?? 50); var logs = response.Hits.Select(hit => new AccessLogEntry @@ -121,7 +124,7 @@ BuildingName = hit.Source.Target?.FirstOrDefault(t => t.Type == "building")?.DisplayName }).ToList(); - return Results.Ok(new AccessLogResponse + return TypedResults.Ok(new AccessLogResponse { Logs = logs, TotalCount = response.Total, @@ -130,7 +133,7 @@ } catch (Exception ex) { - return Results.Problem($"Error fetching access logs: {ex.Message}"); + return TypedResults.Problem($"Error fetching access logs: {ex.Message}"); } }) .WithName("GetAccessLogs"); From 1a3ca3a2a1dc4cc87b198796e9c4ee4dd6bf3757 Mon Sep 17 00:00:00 2001 From: Adam Reed <16042069+iamadamreed@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:45:51 -0500 Subject: [PATCH 5/7] Update test tooling and add real test coverage - Microsoft.NET.Test.Sdk 18.8.1, xunit 2.9.3, coverlet.collector 10.0.1 - Replace placeholder tests with 19 tests: exception hierarchy, UnifiApiResponse semantics, JSON source-gen context round-trips --- Directory.Packages.props | 6 +- .../UnifiAccessJsonContextTests.cs | 183 ++++++++++++++++++ Unifi.NET.Access.Tests/UnitTest1.cs | 10 - .../Exceptions/UnifiExceptionTests.cs | 97 ++++++++++ .../Models/UnifiApiResponseTests.cs | 69 +++++++ Unifi.NET.Common.Tests/UnitTest1.cs | 10 - 6 files changed, 352 insertions(+), 23 deletions(-) create mode 100644 Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs delete mode 100644 Unifi.NET.Access.Tests/UnitTest1.cs create mode 100644 Unifi.NET.Common.Tests/Exceptions/UnifiExceptionTests.cs create mode 100644 Unifi.NET.Common.Tests/Models/UnifiApiResponseTests.cs delete mode 100644 Unifi.NET.Common.Tests/UnitTest1.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index b898974..661bd90 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,12 +26,12 @@ - - + + - + diff --git a/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs b/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs new file mode 100644 index 0000000..a41a8c7 --- /dev/null +++ b/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs @@ -0,0 +1,183 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using FluentAssertions; +using Unifi.NET.Access.Models; +using Unifi.NET.Access.Models.Doors; +using Unifi.NET.Access.Models.Users; +using Unifi.NET.Access.Serialization; + +namespace Unifi.NET.Access.Tests.Serialization; + +/// +/// Verifies that representative models round-trip through the combined +/// resolver, proving Native AOT +/// source-generated serialization coverage across service-specific contexts. +/// +public class UnifiAccessJsonContextTests +{ + private readonly JsonSerializerOptions _options = UnifiAccessJsonContext.CreateOptions(); + + [Fact] + public void CreateOptions_UsesCombinedResolverFromAllContexts() + { + // Act & Assert + _options.TypeInfoResolver.Should().BeSameAs(UnifiAccessJsonContext.Combined); + } + + [Fact] + public void UserResponse_RoundTripsThroughCombinedResolver() + { + // Arrange - snake_case wire format as returned by the UniFi Access API, + // exercising UserJsonContext (registered in the combined resolver). + const string json = """ + { + "id": "usr-123", + "first_name": "Ada", + "last_name": "Lovelace", + "full_name": "Ada Lovelace", + "user_email": "ada@example.com", + "status": "ACTIVE", + "access_policy_ids": ["policy-1", "policy-2"] + } + """; + + var typeInfo = (JsonTypeInfo)_options.GetTypeInfo(typeof(UserResponse)); + + // Act + var user = JsonSerializer.Deserialize(json, typeInfo); + user.Should().NotBeNull(); + var reserialized = JsonSerializer.Serialize(user!, typeInfo); + var roundTripped = JsonSerializer.Deserialize(reserialized, typeInfo); + + // Assert + user!.Id.Should().Be("usr-123"); + user.FirstName.Should().Be("Ada"); + user.LastName.Should().Be("Lovelace"); + user.UserEmail.Should().Be("ada@example.com"); + user.Status.Should().Be("ACTIVE"); + user.AccessPolicyIds.Should().Equal("policy-1", "policy-2"); + + roundTripped.Should().BeEquivalentTo(user); + } + + [Fact] + public void UnifiApiResponse_OfUserResponse_RoundTripsThroughCombinedResolver() + { + // Arrange - full API envelope, exercising the UnifiApiResponse + // wrapper type registered by UserJsonContext. + const string json = """ + { + "code": "SUCCESS", + "msg": "success", + "data": { + "id": "usr-456", + "first_name": "Grace", + "last_name": "Hopper" + } + } + """; + + var typeInfo = (JsonTypeInfo>) + _options.GetTypeInfo(typeof(UnifiApiResponse)); + + // Act + var apiResponse = JsonSerializer.Deserialize(json, typeInfo); + + // Assert + apiResponse.Should().NotBeNull(); + apiResponse!.Code.Should().Be("SUCCESS"); + apiResponse.Data.Should().NotBeNull(); + apiResponse.Data!.FirstName.Should().Be("Grace"); + apiResponse.Data.LastName.Should().Be("Hopper"); + } + + [Fact] + public void DoorResponse_RoundTripsThroughCombinedResolver() + { + // Arrange - exercises DoorJsonContext (a different service-specific + // context) via the same combined resolver used above for UserResponse. + const string json = """ + { + "id": "door-789", + "name": "Front Door", + "full_name": "Main Building / Front Door", + "type": "door", + "is_bind_hub": true, + "door_lock_relay_status": "lock", + "door_position_status": "close" + } + """; + + var typeInfo = (JsonTypeInfo)_options.GetTypeInfo(typeof(DoorResponse)); + + // Act + var door = JsonSerializer.Deserialize(json, typeInfo); + door.Should().NotBeNull(); + var reserialized = JsonSerializer.Serialize(door!, typeInfo); + var roundTripped = JsonSerializer.Deserialize(reserialized, typeInfo); + + // Assert + door!.Id.Should().Be("door-789"); + door.Name.Should().Be("Front Door"); + door.IsBindHub.Should().BeTrue(); + door.DoorLockRelayStatus.Should().Be("lock"); + door.DoorPositionStatus.Should().Be("close"); + + roundTripped.Should().BeEquivalentTo(door); + } + + [Fact] + public void UnifiApiResponse_OfDoorResponseList_RoundTripsThroughCombinedResolver() + { + // Arrange - exercises the List-wrapped envelope type, + // as returned by the "list doors" endpoint. + const string json = """ + { + "code": "SUCCESS", + "msg": "success", + "data": [ + { "id": "door-1", "name": "Lobby", "full_name": "Lobby", "type": "door", "is_bind_hub": false }, + { "id": "door-2", "name": "Garage", "full_name": "Garage", "type": "door", "is_bind_hub": true } + ] + } + """; + + var typeInfo = (JsonTypeInfo>>) + _options.GetTypeInfo(typeof(UnifiApiResponse>)); + + // Act + var apiResponse = JsonSerializer.Deserialize(json, typeInfo); + + // Assert + apiResponse.Should().NotBeNull(); + apiResponse!.Data.Should().HaveCount(2); + apiResponse.Data![0].Id.Should().Be("door-1"); + apiResponse.Data[1].IsBindHub.Should().BeTrue(); + } + + [Fact] + public void UserResponse_Serialize_OmitsNullOptionalPropertiesUsingCamelCaseNames() + { + // Arrange - the source-generated contexts configure camelCase naming + // and omit nulls on write; verify that policy holds through the + // combined resolver too. + var user = new UserResponse + { + Id = "usr-1", + FirstName = "Jane", + LastName = "Doe" + }; + + var typeInfo = (JsonTypeInfo)_options.GetTypeInfo(typeof(UserResponse)); + + // Act + var json = JsonSerializer.Serialize(user, typeInfo); + + // Assert - property names use the explicit [JsonPropertyName] (snake_case) + // values, and unset nullable members are absent from the payload. + json.Should().Contain("\"id\":\"usr-1\""); + json.Should().Contain("\"first_name\":\"Jane\""); + json.Should().NotContain("full_name"); + json.Should().NotContain("nfc_cards"); + } +} diff --git a/Unifi.NET.Access.Tests/UnitTest1.cs b/Unifi.NET.Access.Tests/UnitTest1.cs deleted file mode 100644 index ab505df..0000000 --- a/Unifi.NET.Access.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Unifi.NET.Access.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/Unifi.NET.Common.Tests/Exceptions/UnifiExceptionTests.cs b/Unifi.NET.Common.Tests/Exceptions/UnifiExceptionTests.cs new file mode 100644 index 0000000..544ad5f --- /dev/null +++ b/Unifi.NET.Common.Tests/Exceptions/UnifiExceptionTests.cs @@ -0,0 +1,97 @@ +using FluentAssertions; +using Unifi.NET.Common.Exceptions; + +namespace Unifi.NET.Common.Tests.Exceptions; + +public class UnifiExceptionTests +{ + [Fact] + public void Constructor_WithMessageOnly_SetsMessageAndLeavesErrorInfoNull() + { + // Arrange & Act + var exception = new UnifiException("Something went wrong"); + + // Assert + exception.Message.Should().Be("Something went wrong"); + exception.ErrorCode.Should().BeNull(); + exception.StatusCode.Should().BeNull(); + exception.InnerException.Should().BeNull(); + } + + [Fact] + public void Constructor_WithInnerException_PreservesInnerException() + { + // Arrange + var inner = new InvalidOperationException("root cause"); + + // Act + var exception = new UnifiException("wrapped failure", inner); + + // Assert + exception.Message.Should().Be("wrapped failure"); + exception.InnerException.Should().BeSameAs(inner); + exception.ErrorCode.Should().BeNull(); + exception.StatusCode.Should().BeNull(); + } + + [Fact] + public void Constructor_WithErrorCodeAndStatusCode_PopulatesErrorInfo() + { + // Arrange & Act + var exception = new UnifiException("Invalid parameters", "CODE_PARAMS_INVALID", 400); + + // Assert + exception.Message.Should().Be("Invalid parameters"); + exception.ErrorCode.Should().Be("CODE_PARAMS_INVALID"); + exception.StatusCode.Should().Be(400); + } + + [Fact] + public void UnifiAuthenticationException_IsAUnifiException() + { + // Arrange & Act + var exception = new UnifiAuthenticationException("Auth failed", "CODE_AUTH_FAILED", 401); + + // Assert + exception.Should().BeAssignableTo(); + exception.ErrorCode.Should().Be("CODE_AUTH_FAILED"); + exception.StatusCode.Should().Be(401); + exception.Message.Should().Be("Auth failed"); + } + + [Fact] + public void UnifiNotFoundException_IsAUnifiException() + { + // Arrange & Act + var exception = new UnifiNotFoundException("Resource not found", "CODE_RESOURCE_NOT_FOUND", 404); + + // Assert + exception.Should().BeAssignableTo(); + exception.ErrorCode.Should().Be("CODE_RESOURCE_NOT_FOUND"); + exception.StatusCode.Should().Be(404); + } + + [Fact] + public void UnifiAuthenticationException_MessageOnlyConstructor_LeavesErrorInfoNull() + { + // Arrange & Act + var exception = new UnifiAuthenticationException("Auth failed"); + + // Assert + exception.Message.Should().Be("Auth failed"); + exception.ErrorCode.Should().BeNull(); + exception.StatusCode.Should().BeNull(); + } + + [Fact] + public void UnifiNotFoundException_MessageOnlyConstructor_LeavesErrorInfoNull() + { + // Arrange & Act + var exception = new UnifiNotFoundException("Not found"); + + // Assert + exception.Message.Should().Be("Not found"); + exception.ErrorCode.Should().BeNull(); + exception.StatusCode.Should().BeNull(); + } +} diff --git a/Unifi.NET.Common.Tests/Models/UnifiApiResponseTests.cs b/Unifi.NET.Common.Tests/Models/UnifiApiResponseTests.cs new file mode 100644 index 0000000..72181db --- /dev/null +++ b/Unifi.NET.Common.Tests/Models/UnifiApiResponseTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using Unifi.NET.Common.Models; + +namespace Unifi.NET.Common.Tests.Models; + +public class UnifiApiResponseTests +{ + [Fact] + public void IsSuccess_WhenCodeIsSuccess_ReturnsTrue() + { + // Arrange + var response = new UnifiApiResponse + { + Code = "SUCCESS", + Message = "success", + Data = "payload" + }; + + // Act & Assert + response.IsSuccess.Should().BeTrue(); + } + + [Theory] + [InlineData("CODE_PARAMS_INVALID")] + [InlineData("CODE_AUTH_FAILED")] + [InlineData("")] + public void IsSuccess_WhenCodeIsNotSuccess_ReturnsFalse(string code) + { + // Arrange + var response = new UnifiApiResponse + { + Code = code, + Message = "failure" + }; + + // Act & Assert + response.IsSuccess.Should().BeFalse(); + } + + [Fact] + public void Data_WhenNotSet_DefaultsToNullForReferenceType() + { + // Arrange + var response = new UnifiApiResponse + { + Code = "SUCCESS", + Message = "success" + }; + + // Act & Assert + response.Data.Should().BeNull(); + } + + [Fact] + public void Data_CarriesTypedPayload() + { + // Arrange + var response = new UnifiApiResponse> + { + Code = "SUCCESS", + Message = "success", + Data = [1, 2, 3] + }; + + // Act & Assert + response.Data.Should().BeEquivalentTo([1, 2, 3]); + response.IsSuccess.Should().BeTrue(); + } +} diff --git a/Unifi.NET.Common.Tests/UnitTest1.cs b/Unifi.NET.Common.Tests/UnitTest1.cs deleted file mode 100644 index 556ac74..0000000 --- a/Unifi.NET.Common.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Unifi.NET.Common.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} From e45fca65c36784794f10eb661d607709d1727576 Mon Sep 17 00:00:00 2001 From: Adam Reed <16042069+iamadamreed@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:45:51 -0500 Subject: [PATCH 6/7] Update docs to .NET 10 --- CLAUDE.md | 14 +++++++++----- README.md | 10 +++++----- Unifi.NET.Samples/README.md | 2 +- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index eb380bf..5257e40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ DEFAULT_BRANCH: "main" ## Project Overview -Unifi.NET is a collection of .NET 9 SDKs for Ubiquiti UniFi APIs. The solution wraps UniFi service APIs (Access, Network, Protect, Site Manager) with strongly-typed, Native AOT-compatible client libraries. +Unifi.NET is a collection of .NET 10 SDKs for Ubiquiti UniFi APIs. The solution wraps UniFi service APIs (Access, Network, Protect, Site Manager) with strongly-typed, Native AOT-compatible client libraries. ## Solution Structure @@ -28,7 +28,7 @@ Unifi.NET.sln ├── Unifi.NET.Samples/ # Sample console application ├── Directory.Build.props # Shared MSBuild properties ├── Directory.Packages.props # Central package management -├── global.json # .NET SDK version (9.0.100) +├── global.json # .NET SDK version (10.0.100) └── .editorconfig # Code style configuration ``` @@ -190,11 +190,15 @@ NuGet packages follow folder structure: Central package versions (Directory.Packages.props): - **RestSharp**: 112.1.0 -- **Microsoft.Extensions.Http.Resilience**: 9.0.0 -- **System.Text.Json**: 9.0.0 -- **xunit**: 2.9.2 +- **Microsoft.Extensions.Http.Resilience**: 10.0.0 +- **Microsoft.OpenApi**: 2.11.0 (pinned for GHSA-v5pm-xwqc-g5wc) +- **Microsoft.NET.Test.Sdk**: 18.8.1 +- **xunit**: 2.9.3 - **FluentAssertions**: 6.12.2 - **Moq**: 4.20.72 +- **Microsoft.CodeAnalysis.NetAnalyzers**: 10.0.0 + +Note: `System.Text.Json` is framework-provided in .NET 10, so it no longer has an explicit `PackageVersion` entry. ## API Implementation Workflow diff --git a/README.md b/README.md index e85e506..12b30ca 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A comprehensive collection of .NET SDKs for interacting with Ubiquiti UniFi APIs [![NuGet](https://img.shields.io/nuget/v/Unifi.NET.Access.svg)](https://www.nuget.org/packages/Unifi.NET.Access/) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![.NET](https://img.shields.io/badge/.NET-9.0-512BD4)](https://dotnet.microsoft.com/download) +[![.NET](https://img.shields.io/badge/.NET-10.0-512BD4)](https://dotnet.microsoft.com/download) ## 📦 Available Packages @@ -30,13 +30,13 @@ A comprehensive collection of .NET SDKs for interacting with Ubiquiti UniFi APIs ## 📋 Prerequisites -- .NET 9.0 or later +- .NET 10.0 or later - UniFi Console with the respective service installed - API Token from UniFi Portal (for authentication) - Network access to your UniFi Console (default port: 12445 for Access) ### Native AOT Requirements -- .NET 9 SDK for building +- .NET 10 SDK for building - Target platform runtime identifier (e.g., `linux-x64`, `win-x64`, `osx-arm64`) - No additional runtime dependencies needed for deployment @@ -129,7 +129,7 @@ var users = await accessClient.Users.GetAllAsync(); ### Native AOT Deployment -The SDKs are designed for full Native AOT compatibility in .NET 9: +The SDKs are designed for full Native AOT compatibility in .NET 10: ```xml @@ -209,7 +209,7 @@ Unifi.NET/ ### Native AOT Design Principles -All SDKs follow .NET 9 Native AOT best practices: +All SDKs follow .NET 10 Native AOT best practices: #### ✅ **Fully Compatible Components** - **System.Text.Json** with source generators (no reflection) diff --git a/Unifi.NET.Samples/README.md b/Unifi.NET.Samples/README.md index aefd1a7..7202c9b 100644 --- a/Unifi.NET.Samples/README.md +++ b/Unifi.NET.Samples/README.md @@ -15,7 +15,7 @@ The UniFi Access sample is a console application that demonstrates key features 1. **UniFi Access Console**: You need access to a UniFi Access console (v1.9.1 or later) 2. **API Token**: Generate an API token from your UniFi Access console -3. **.NET 9 SDK**: Install the .NET 9 SDK from [dotnet.microsoft.com](https://dotnet.microsoft.com) +3. **.NET 10 SDK**: Install the .NET 10 SDK from [dotnet.microsoft.com](https://dotnet.microsoft.com) ### Getting Started From 90c5285f78ec566f761b946a6b6001686b978986 Mon Sep 17 00:00:00 2001 From: Adam Reed <16042069+iamadamreed@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:14:14 -0500 Subject: [PATCH 7/7] Fix empty IEnumerable returns regressing to arrays Target-typed [] on a Task> materializes as T[], not List, so empty results silently became fixed-size and uncastable to List. Restore new List() on those paths; [] stays where the target is List or byte[]. Also renames a JSON test whose name said camelCase while asserting snake_case [JsonPropertyName] values. --- .../Serialization/UnifiAccessJsonContextTests.cs | 8 ++++---- Unifi.NET.Access/Services/AccessPolicyService.cs | 2 +- Unifi.NET.Access/Services/CredentialService.cs | 2 +- Unifi.NET.Access/Services/DeviceService.cs | 2 +- Unifi.NET.Access/Services/DoorService.cs | 2 +- Unifi.NET.Access/Services/UserGroupService.cs | 4 ++-- Unifi.NET.Access/Services/UserService.cs | 6 +++--- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs b/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs index a41a8c7..cf60c2c 100644 --- a/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs +++ b/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs @@ -156,11 +156,11 @@ public void UnifiApiResponse_OfDoorResponseList_RoundTripsThroughCombinedResolve } [Fact] - public void UserResponse_Serialize_OmitsNullOptionalPropertiesUsingCamelCaseNames() + public void UserResponse_Serialize_OmitsNullOptionalPropertiesUsingJsonPropertyNames() { - // Arrange - the source-generated contexts configure camelCase naming - // and omit nulls on write; verify that policy holds through the - // combined resolver too. + // Arrange - the wire format comes from the explicit [JsonPropertyName] + // attributes (snake_case), and nulls are omitted on write; verify that + // policy holds through the combined resolver too. var user = new UserResponse { Id = "usr-1", diff --git a/Unifi.NET.Access/Services/AccessPolicyService.cs b/Unifi.NET.Access/Services/AccessPolicyService.cs index f96622f..e724bf6 100644 --- a/Unifi.NET.Access/Services/AccessPolicyService.cs +++ b/Unifi.NET.Access/Services/AccessPolicyService.cs @@ -51,6 +51,6 @@ public async Task GetAccessPolicyAsync(string policyId, Ca public async Task> GetAccessPoliciesAsync(CancellationToken cancellationToken = default) { var policies = await GetAsync>("/api/v1/developer/access_policies", cancellationToken); - return policies ?? []; + return policies ?? new List(); } } \ No newline at end of file diff --git a/Unifi.NET.Access/Services/CredentialService.cs b/Unifi.NET.Access/Services/CredentialService.cs index 19b7608..bffb4b9 100644 --- a/Unifi.NET.Access/Services/CredentialService.cs +++ b/Unifi.NET.Access/Services/CredentialService.cs @@ -157,6 +157,6 @@ public async Task> ImportNfcCardsAsync(Impor var jsonTypeInfo = (JsonTypeInfo>>)_jsonOptions.GetTypeInfo(typeof(UnifiApiResponse>)); var apiResponse = JsonSerializer.Deserialize(response.Content ?? "{}", jsonTypeInfo); - return apiResponse?.Data ?? []; + return apiResponse?.Data ?? new List(); } } \ No newline at end of file diff --git a/Unifi.NET.Access/Services/DeviceService.cs b/Unifi.NET.Access/Services/DeviceService.cs index 4aada1a..3eeaaa1 100644 --- a/Unifi.NET.Access/Services/DeviceService.cs +++ b/Unifi.NET.Access/Services/DeviceService.cs @@ -29,6 +29,6 @@ public async Task> GetDevicesAsync(CancellationToken return devices.SelectMany(list => list ?? new List()); } - return []; + return new List(); } } \ No newline at end of file diff --git a/Unifi.NET.Access/Services/DoorService.cs b/Unifi.NET.Access/Services/DoorService.cs index 52a4dc0..f8ea2ab 100644 --- a/Unifi.NET.Access/Services/DoorService.cs +++ b/Unifi.NET.Access/Services/DoorService.cs @@ -28,7 +28,7 @@ public async Task GetDoorAsync(string doorId, CancellationToken ca public async Task> GetDoorsAsync(CancellationToken cancellationToken = default) { var doors = await GetAsync>("/api/v1/developer/doors", cancellationToken); - return doors ?? []; + return doors ?? new List(); } /// diff --git a/Unifi.NET.Access/Services/UserGroupService.cs b/Unifi.NET.Access/Services/UserGroupService.cs index 35bf409..e0e6337 100644 --- a/Unifi.NET.Access/Services/UserGroupService.cs +++ b/Unifi.NET.Access/Services/UserGroupService.cs @@ -212,7 +212,7 @@ public async Task> SearchUserGroupsAsync(string k { ArgumentException.ThrowIfNullOrEmpty(keyword); var groups = await GetAsync>($"/api/v1/developer/user_groups/search?keyword={Uri.EscapeDataString(keyword)}", cancellationToken); - return groups ?? []; + return groups ?? new List(); } /// @@ -235,6 +235,6 @@ public async Task> ImportUserGroupsAsync(I var jsonTypeInfo = (JsonTypeInfo>>)_jsonOptions.GetTypeInfo(typeof(UnifiApiResponse>)); var apiResponse = JsonSerializer.Deserialize(response.Content ?? "{}", jsonTypeInfo); - return apiResponse?.Data ?? []; + return apiResponse?.Data ?? new List(); } } \ No newline at end of file diff --git a/Unifi.NET.Access/Services/UserService.cs b/Unifi.NET.Access/Services/UserService.cs index d53918d..a96d6b0 100644 --- a/Unifi.NET.Access/Services/UserService.cs +++ b/Unifi.NET.Access/Services/UserService.cs @@ -62,7 +62,7 @@ public async Task> GetUsersAsync(CancellationToken can if (!countResponse.IsSuccessful || string.IsNullOrEmpty(countResponse.Content)) { - return []; + return new List(); } // Parse to get total count @@ -73,7 +73,7 @@ public async Task> GetUsersAsync(CancellationToken can { // Fallback to old method if pagination is not available var users = await GetAsync>("/api/v1/developer/users?expand[]=access_policy", cancellationToken); - return users ?? []; + return users ?? new List(); } var total = paginatedResponse.Pagination.Total; @@ -215,7 +215,7 @@ public async Task> GetUserAccessPoliciesAsync( var user = await GetUserAsync(userId, cancellationToken); if (user.AccessPolicies == null) { - return []; + return new List(); } // Convert AccessPolicyInfo to AccessPolicyResponse