Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
31 changes: 12 additions & 19 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
```

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
Expand Down
44 changes: 22 additions & 22 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,34 @@
<ItemGroup>
<!-- Core Dependencies -->
<PackageVersion Include="RestSharp" Version="112.1.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.0" />
<PackageVersion Include="System.Text.Json" Version="9.0.0" />

<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />

<!-- ASP.NET Core -->
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.0" />

<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.0" />

<!-- Testing -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageVersion Include="xunit" Version="2.9.2" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="FluentAssertions" Version="6.12.2" />
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />

<!-- Analyzers -->
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.0" />
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion Dockerfile.windows-cross
Original file line number Diff line number Diff line change
@@ -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

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
<!-- In your project file -->
Expand Down Expand Up @@ -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)
Expand Down
183 changes: 183 additions & 0 deletions Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Verifies that representative models round-trip through the combined
/// <see cref="UnifiAccessJsonContext"/> resolver, proving Native AOT
/// source-generated serialization coverage across service-specific contexts.
/// </summary>
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<UserResponse>)_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<UserResponse>
// 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<UnifiApiResponse<UserResponse>>)
_options.GetTypeInfo(typeof(UnifiApiResponse<UserResponse>));

// 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<DoorResponse>)_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<DoorResponse>-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<UnifiApiResponse<List<DoorResponse>>>)
_options.GetTypeInfo(typeof(UnifiApiResponse<List<DoorResponse>>));

// 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<UserResponse>)_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");
}
}
2 changes: 1 addition & 1 deletion Unifi.NET.Access.Tests/Unifi.NET.Access.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
Expand Down
10 changes: 0 additions & 10 deletions Unifi.NET.Access.Tests/UnitTest1.cs

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ namespace Unifi.NET.Access.Serialization.Contexts;
// API wrapper types
[JsonSerializable(typeof(UnifiApiResponse<AccessPolicyResponse>))]
[JsonSerializable(typeof(UnifiApiResponse<List<AccessPolicyResponse>>))]
internal partial class AccessPolicyJsonContext : JsonSerializerContext
internal sealed partial class AccessPolicyJsonContext : JsonSerializerContext
{
}
Loading