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/CLAUDE.md b/CLAUDE.md
index cb3413c..5257e40 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,28 +2,17 @@
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"
```
## 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
@@ -39,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
```
@@ -201,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/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..661bd90 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,34 +6,34 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
-
-
+
+
-
+
-
+
\ 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/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
[](https://www.nuget.org/packages/Unifi.NET.Access/)
[](LICENSE)
-[](https://dotnet.microsoft.com/download)
+[](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.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs b/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs
new file mode 100644
index 0000000..cf60c2c
--- /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_OmitsNullOptionalPropertiesUsingJsonPropertyNames()
+ {
+ // 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",
+ 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/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.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.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/CredentialService.cs b/Unifi.NET.Access/Services/CredentialService.cs
index 4fd34e6..bffb4b9 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
diff --git a/Unifi.NET.Access/Services/DeviceService.cs b/Unifi.NET.Access/Services/DeviceService.cs
index 16accfe..3eeaaa1 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();
}
}
\ No newline at end of file
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..e0e6337 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
diff --git a/Unifi.NET.Access/Services/UserService.cs b/Unifi.NET.Access/Services/UserService.cs
index b60ca13..a96d6b0 100644
--- a/Unifi.NET.Access/Services/UserService.cs
+++ b/Unifi.NET.Access/Services/UserService.cs
@@ -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
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/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");
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/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/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.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()
- {
-
- }
-}
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/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
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