Initial BinaryLane v2 API client

This commit is contained in:
2026-08-16 21:52:42 +10:00
commit fcd0d22b0d
91 changed files with 18691 additions and 0 deletions
@@ -0,0 +1,3 @@
using Xunit;
[assembly: CollectionBehavior(DisableTestParallelization = true)]
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/BinaryLane.Api/BinaryLane.Api.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,375 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using BinaryLane.Api.V2.Authentication;
using BinaryLane.Api.V2.Errors;
using BinaryLane.Api.V2.Http;
using Xunit;
namespace BinaryLane.Api.Tests;
public sealed class BinaryLaneHttpExecutorTests
{
[Fact]
public async Task GetAsyncAddsBearerHeaderAndSerializesQueryValues()
{
HttpRequestMessage? received = null;
using var httpClient = new HttpClient(new HttpClientTestHandler((request, _) =>
{
received = request;
return Task.FromResult(JsonResponse(HttpStatusCode.OK, "{\"value\":\"ok\"}"));
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(httpClient, new StaticBinaryLaneTokenProvider("test-token"));
var response = await executor.GetAsync<JsonElement>(
"v2/servers",
new System.Collections.Generic.Dictionary<string, object?>
{
["page"] = 2,
["include_archived"] = false,
});
Assert.Equal("ok", response.GetProperty("value").GetString());
Assert.NotNull(received);
Assert.Equal("Bearer", received!.Headers.Authorization!.Scheme);
Assert.Equal("test-token", received.Headers.Authorization.Parameter);
Assert.Equal("?page=2&include_archived=false", received.RequestUri!.Query);
}
[Fact]
public async Task GetAsyncMapsUnauthorizedResponseWithoutLeakingBearerToken()
{
using var httpClient = new HttpClient(new HttpClientTestHandler((_, _) =>
Task.FromResult(JsonResponse(HttpStatusCode.Unauthorized, "{\"title\":\"Unauthorized\",\"detail\":\"Invalid token\"}"))))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(httpClient, new StaticBinaryLaneTokenProvider("super-secret-token"));
var exception = await Assert.ThrowsAsync<BinaryLaneUnauthorizedException>(
() => executor.GetAsync<JsonElement>("v2/account"));
Assert.Equal(HttpStatusCode.Unauthorized, exception.StatusCode);
Assert.Equal("Invalid token", exception.Problem!.Detail);
Assert.DoesNotContain("Invalid token", exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain("super-secret-token", exception.ToString(), StringComparison.Ordinal);
Assert.DoesNotContain("super-secret-token", exception.ResponseBody ?? string.Empty, StringComparison.Ordinal);
}
[Fact]
public async Task GetAsyncPropagatesCancellationToTheHttpHandler()
{
using var cancellation = new CancellationTokenSource();
using var httpClient = new HttpClient(new HttpClientTestHandler(async (_, token) =>
{
await Task.Delay(Timeout.InfiniteTimeSpan, token);
throw new InvalidOperationException("Unreachable.");
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(httpClient, new StaticBinaryLaneTokenProvider("token"));
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => executor.GetAsync<JsonElement>("v2/account", cancellationToken: cancellation.Token));
}
[Fact]
public async Task TokenProviderIsEvaluatedForEveryRequestSoTokensCanRotate()
{
var authorizationTokens = new List<string?>();
using var httpClient = new HttpClient(new HttpClientTestHandler((request, _) =>
{
authorizationTokens.Add(request.Headers.Authorization?.Parameter);
return Task.FromResult(JsonResponse(HttpStatusCode.OK, "{}"));
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(
httpClient,
new RotatingTokenProvider("first-token", "second-token"));
await executor.GetAsync<JsonElement>("v2/account");
await executor.GetAsync<JsonElement>("v2/account");
Assert.Collection(
authorizationTokens,
token => Assert.Equal("first-token", token),
token => Assert.Equal("second-token", token));
}
[Fact]
public async Task QueryFormattingOmitsNullsAndUsesInvariantWireValues()
{
HttpRequestMessage? received = null;
using var httpClient = new HttpClient(new HttpClientTestHandler((request, _) =>
{
received = request;
return Task.FromResult(JsonResponse(HttpStatusCode.OK, "{}"));
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(httpClient, new StaticBinaryLaneTokenProvider("token"));
var timestamp = new DateTimeOffset(2026, 8, 16, 12, 34, 56, TimeSpan.Zero);
await executor.GetAsync<JsonElement>(
"v2/actions?existing=true",
new Dictionary<string, object?>
{
["after"] = timestamp,
["amount"] = 10.5m,
["omit_me"] = null,
});
Assert.NotNull(received);
var query = received!.RequestUri!.Query;
Assert.Contains("existing=true", query, StringComparison.Ordinal);
Assert.Contains("after=2026-08-16T12%3A34%3A56.0000000%2B00%3A00", query, StringComparison.Ordinal);
Assert.Contains("amount=10.5", query, StringComparison.Ordinal);
Assert.DoesNotContain("omit_me", query, StringComparison.Ordinal);
}
[Fact]
public async Task ValidationErrorsMapToValidationExceptionAndPreserveProblemDetails()
{
using var httpClient = new HttpClient(new HttpClientTestHandler((_, _) =>
Task.FromResult(JsonResponse(
(HttpStatusCode)422,
"{\"title\":\"Validation failed\",\"detail\":\"region is required\",\"status\":422}"))))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(httpClient, new StaticBinaryLaneTokenProvider("token"));
var exception = await Assert.ThrowsAsync<BinaryLaneValidationException>(
() => executor.PostAsync<JsonElement>("v2/servers", new { }));
Assert.Equal((HttpStatusCode)422, exception.StatusCode);
Assert.Equal("Validation failed", exception.Problem!.Title);
Assert.Equal("region is required", exception.Problem.Detail);
}
[Fact]
public async Task MutationsAreNotRetriedImplicitly()
{
var requestCount = 0;
using var httpClient = new HttpClient(new HttpClientTestHandler((_, _) =>
{
requestCount++;
return Task.FromResult(JsonResponse(HttpStatusCode.InternalServerError, "{\"title\":\"Failure\"}"));
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(httpClient, new StaticBinaryLaneTokenProvider("token"));
await Assert.ThrowsAsync<BinaryLaneApiException>(
() => executor.PostAsync<JsonElement>("v2/servers", new { name = "must-not-retry" }));
Assert.Equal(1, requestCount);
}
[Fact]
public async Task EmptyTokenFailsBeforeTheRequestIsDispatched()
{
var requestCount = 0;
using var httpClient = new HttpClient(new HttpClientTestHandler((_, _) =>
{
requestCount++;
return Task.FromResult(JsonResponse(HttpStatusCode.OK, "{}"));
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(httpClient, new RotatingTokenProvider(string.Empty));
await Assert.ThrowsAsync<InvalidOperationException>(
() => executor.GetAsync<JsonElement>("v2/account"));
Assert.Equal(0, requestCount);
}
[Fact]
public async Task AbsoluteUrlsOutsideTheConfiguredOriginDoNotReceiveTheBearerToken()
{
var requestCount = 0;
using var httpClient = new HttpClient(new HttpClientTestHandler((_, _) =>
{
requestCount++;
return Task.FromResult(JsonResponse(HttpStatusCode.OK, "{}"));
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(httpClient, new StaticBinaryLaneTokenProvider("token"));
await Assert.ThrowsAsync<ArgumentException>(
() => executor.GetAsync<JsonElement>("https://other.example.test/v2/servers"));
Assert.Equal(0, requestCount);
}
[Fact]
public async Task NonHttpAbsoluteUrlsAreRejectedBeforeTheRequestIsDispatched()
{
var requestCount = 0;
using var httpClient = new HttpClient(new HttpClientTestHandler((_, _) =>
{
requestCount++;
return Task.FromResult(JsonResponse(HttpStatusCode.OK, "{}"));
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(httpClient, new StaticBinaryLaneTokenProvider("token"));
await Assert.ThrowsAsync<ArgumentException>(
() => executor.GetAsync<JsonElement>("ftp://other.example.test/v2/servers"));
Assert.Equal(0, requestCount);
}
[Fact]
public async Task CallerCannotOverrideTheTokenProviderAuthorizationHeader()
{
var requestCount = 0;
using var httpClient = new HttpClient(new HttpClientTestHandler((_, _) =>
{
requestCount++;
return Task.FromResult(JsonResponse(HttpStatusCode.OK, "{}"));
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(httpClient, new StaticBinaryLaneTokenProvider("token"));
var request = new BinaryLaneRequest(HttpMethod.Get, "v2/account")
{
Headers = new Dictionary<string, string>
{
["Authorization"] = "Bearer caller-token",
},
};
await Assert.ThrowsAsync<ArgumentException>(() => executor.SendAsync<JsonElement>(request));
Assert.Equal(0, requestCount);
}
[Fact]
public async Task CallerCannotOverrideTheRequestAuthorityWithAHostHeader()
{
var requestCount = 0;
using var httpClient = new HttpClient(new HttpClientTestHandler((_, _) =>
{
requestCount++;
return Task.FromResult(JsonResponse(HttpStatusCode.OK, "{}"));
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(httpClient, new StaticBinaryLaneTokenProvider("token"));
var request = new BinaryLaneRequest(HttpMethod.Get, "v2/account")
{
Headers = new Dictionary<string, string>
{
["Host"] = "other.example.test",
},
};
await Assert.ThrowsAsync<ArgumentException>(() => executor.SendAsync<JsonElement>(request));
Assert.Equal(0, requestCount);
}
[Fact]
public async Task PlaintextBaseAddressesAreRejectedBeforeTheRequestIsDispatched()
{
var requestCount = 0;
using var httpClient = new HttpClient(new HttpClientTestHandler((_, _) =>
{
requestCount++;
return Task.FromResult(JsonResponse(HttpStatusCode.OK, "{}"));
}))
{
BaseAddress = new Uri("http://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(httpClient, new StaticBinaryLaneTokenProvider("token"));
await Assert.ThrowsAsync<InvalidOperationException>(
() => executor.GetAsync<JsonElement>("v2/account"));
Assert.Equal(0, requestCount);
}
[Fact]
public async Task SuccessResponseBodiesLargerThanTheLimitAreRejectedBeforeBuffering()
{
using var httpClient = new HttpClient(new HttpClientTestHandler((_, _) =>
{
var content = new ByteArrayContent(Array.Empty<byte>());
content.Headers.ContentLength = (16L * 1024 * 1024) + 1;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content });
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(httpClient, new StaticBinaryLaneTokenProvider("token"));
var exception = await Assert.ThrowsAsync<HttpRequestException>(
() => executor.GetAsync<JsonElement>("v2/account"));
Assert.Contains("exceeded", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task ErrorResponseBodyIsBoundedBeforeItIsStoredOnTheException()
{
var responseBody = "{\"detail\":\"" + new string('x', 40_000) + "\"}";
using var httpClient = new HttpClient(new HttpClientTestHandler((_, _) =>
Task.FromResult(JsonResponse(HttpStatusCode.InternalServerError, responseBody))))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var executor = new BinaryLaneHttpExecutor(httpClient, new StaticBinaryLaneTokenProvider("token"));
var exception = await Assert.ThrowsAsync<BinaryLaneApiException>(
() => executor.GetAsync<JsonElement>("v2/account"));
Assert.NotNull(exception.ResponseBody);
Assert.Equal(32_769, exception.ResponseBody!.Length);
Assert.EndsWith("…", exception.ResponseBody, StringComparison.Ordinal);
}
private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, string json) =>
new(statusCode)
{
Content = new StringContent(json),
};
private sealed class RotatingTokenProvider : IBinaryLaneTokenProvider
{
private readonly Queue<string> _tokens;
public RotatingTokenProvider(params string[] tokens)
{
_tokens = new Queue<string>(tokens);
}
public ValueTask<string> GetTokenAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return new ValueTask<string>(_tokens.Dequeue());
}
}
}
@@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using BinaryLane.Api.V2;
using BinaryLane.Api.V2.Authentication;
using BinaryLane.Api.V2.Models;
using Xunit;
namespace BinaryLane.Api.Tests;
public sealed class ClientResourceTests
{
[Fact]
public async Task ServersListAllFollowsTheProviderNextPageLink()
{
var requestCount = 0;
using var httpClient = new HttpClient(new HttpClientTestHandler((request, _) =>
{
requestCount++;
var json = request.RequestUri!.Query.Contains("page=2", StringComparison.Ordinal)
? "{\"meta\":{\"total\":2},\"links\":{\"pages\":{}},\"servers\":[{\"id\":2,\"name\":\"two\",\"status\":\"active\"}]}"
: "{\"meta\":{\"total\":2},\"links\":{\"pages\":{\"next\":\"/v2/servers?page=2\"}},\"servers\":[{\"id\":1,\"name\":\"one\",\"status\":\"active\"}]}";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) });
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var client = new BinaryLaneClient(httpClient, new StaticBinaryLaneTokenProvider("token"));
var servers = new List<Server>();
await foreach (var server in client.Servers.ListAllAsync())
{
servers.Add(server);
}
Assert.Equal(2, requestCount);
Assert.Collection(
servers,
server => Assert.Equal("one", server.Name),
server => Assert.Equal("two", server.Name));
}
[Fact]
public async Task ServersListAllStopsWhenTheProviderRepeatsANextPageLink()
{
var requestCount = 0;
using var httpClient = new HttpClient(new HttpClientTestHandler((_, _) =>
{
requestCount++;
const string json = "{\"meta\":{\"total\":1},\"links\":{\"pages\":{\"next\":\"/v2/servers?page=1\"}},\"servers\":[{\"id\":1,\"name\":\"one\",\"status\":\"active\"}]}";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) });
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var client = new BinaryLaneClient(httpClient, new StaticBinaryLaneTokenProvider("token"));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in client.Servers.ListAllAsync())
{
}
});
Assert.Contains("repeated pagination link", exception.Message, StringComparison.Ordinal);
Assert.Equal(2, requestCount);
}
[Fact]
public async Task SubmitActionUsesRealActionsEndpointAndReportsAcceptedStatus()
{
HttpRequestMessage? received = null;
string? body = null;
using var httpClient = new HttpClient(new HttpClientTestHandler(async (request, cancellationToken) =>
{
received = request;
body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.Accepted)
{
Content = new StringContent("{\"action\":{\"id\":9,\"type\":\"power_on\",\"status\":\"in-progress\",\"started_at\":\"2026-01-01T00:00:00Z\"}}"),
};
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var client = new BinaryLaneClient(httpClient, new StaticBinaryLaneTokenProvider("token"));
var submission = await client.Servers.SubmitActionAsync(123, new PowerOnServerAction());
Assert.NotNull(received);
Assert.Equal("/v2/servers/123/actions", received!.RequestUri!.AbsolutePath);
Assert.Equal("POST", received.Method.Method);
Assert.Contains("\"type\":\"power_on\"", body!, StringComparison.Ordinal);
Assert.True(submission.IsAccepted);
Assert.Equal(9, submission.Action!.Id);
}
[Fact]
public async Task ListEndpointsRejectAResponseWithoutTheExpectedCollectionEnvelope()
{
using var httpClient = new HttpClient(new HttpClientTestHandler((_, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"meta\":{\"total\":0},\"wrong_collection\":[]}"),
})))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var client = new BinaryLaneClient(httpClient, new StaticBinaryLaneTokenProvider("token"));
var exception = await Assert.ThrowsAsync<JsonException>(() => client.Servers.ListAsync());
Assert.Contains("servers", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task UserDataUsesTheDocumentedDirectResponseBody()
{
using var httpClient = new HttpClient(new HttpClientTestHandler((request, _) =>
{
Assert.Equal("/v2/servers/123/user_data", request.RequestUri!.AbsolutePath);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"user_data\":\"#cloud-config\"}"),
});
}))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var client = new BinaryLaneClient(httpClient, new StaticBinaryLaneTokenProvider("token"));
var userData = await client.Servers.GetUserDataAsync(123);
Assert.Equal("#cloud-config", userData.Value);
}
[Fact]
public async Task ListEndpointsRejectNullItemsRatherThanSilentlyDroppingThem()
{
using var httpClient = new HttpClient(new HttpClientTestHandler((_, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"meta\":{\"total\":1},\"servers\":[null]}"),
})))
{
BaseAddress = new Uri("https://api.example.test/"),
};
var client = new BinaryLaneClient(httpClient, new StaticBinaryLaneTokenProvider("token"));
var exception = await Assert.ThrowsAsync<JsonException>(() => client.Servers.ListAsync());
Assert.Contains("servers", exception.Message, StringComparison.Ordinal);
}
}
@@ -0,0 +1,85 @@
using System;
using BinaryLane.Api.V2;
using BinaryLane.Api.V2.Configuration;
using BinaryLane.Api.V2.DependencyInjection;
using BinaryLane.Api.V2.Resources;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
namespace BinaryLane.Api.Tests;
public sealed class DependencyInjectionTests
{
[Fact]
public void AddBinaryLaneApiRegistersAComposableClient()
{
var services = new ServiceCollection();
services.AddBinaryLaneApi(options => options.ApiToken = "test-token");
using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<IBinaryLaneClient>();
Assert.NotNull(client.Account);
Assert.NotNull(client.Actions);
Assert.NotNull(client.Servers);
Assert.NotNull(client.Regions);
Assert.NotNull(client.Vpcs);
Assert.NotNull(provider.GetRequiredService<IServersApi>());
Assert.NotNull(provider.GetRequiredService<IDomainsApi>());
}
[Fact]
public void InvalidOptionsAreRejectedWhenResolved()
{
var services = new ServiceCollection();
services.AddBinaryLaneApi(options =>
{
options.BaseUrl = "not-a-url";
options.ApiToken = "test-token";
});
using var provider = services.BuildServiceProvider();
Assert.Throws<OptionsValidationException>(
() => provider.GetRequiredService<IOptions<BinaryLaneOptions>>().Value);
}
[Theory]
[InlineData(0)]
[InlineData(301)]
public void OutOfRangeTimeoutIsRejectedWhenResolved(int timeoutSeconds)
{
var services = new ServiceCollection();
services.AddBinaryLaneApi(options =>
{
options.ApiToken = "test-token";
options.RequestTimeoutSeconds = timeoutSeconds;
});
using var provider = services.BuildServiceProvider();
Assert.Throws<OptionsValidationException>(
() => provider.GetRequiredService<IOptions<BinaryLaneOptions>>().Value);
}
[Theory]
[InlineData("http://api.example.test/")]
[InlineData("https://user:password@api.example.test/")]
[InlineData("https://api.example.test/?unexpected=value")]
[InlineData("https://api.example.test/#fragment")]
public void BaseUrlWithCredentialsQueryOrFragmentIsRejected(string baseUrl)
{
var services = new ServiceCollection();
services.AddBinaryLaneApi(options =>
{
options.BaseUrl = baseUrl;
options.ApiToken = "test-token";
});
using var provider = services.BuildServiceProvider();
Assert.Throws<OptionsValidationException>(
() => provider.GetRequiredService<IOptions<BinaryLaneOptions>>().Value);
}
}
@@ -0,0 +1,17 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace BinaryLane.Api.Tests;
internal sealed class HttpClientTestHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _send;
public HttpClientTestHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send) =>
_send = send ?? throw new ArgumentNullException(nameof(send));
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
_send(request, cancellationToken);
}
@@ -0,0 +1,149 @@
using System.Text.Json;
using BinaryLane.Api.V2.Http;
using BinaryLane.Api.V2.Models;
using Xunit;
namespace BinaryLane.Api.Tests;
public sealed class ModelSerializationTests
{
private static readonly JsonSerializerOptions Json =
new BinaryLaneJsonSerializerOptions().SerializerOptions;
[Fact]
public void PowerOnActionSerializesTheProviderDiscriminator()
{
string json = JsonSerializer.Serialize<ServerAction>(new PowerOnServerAction(), Json);
using JsonDocument document = JsonDocument.Parse(json);
Assert.Equal("power_on", document.RootElement.GetProperty("type").GetString());
Assert.Single(document.RootElement.EnumerateObject());
}
[Fact]
public void CreateServerRequestSerializesBinaryLaneWireNamesAndOmitsNulls()
{
CreateServerRequest request = new()
{
Name = "demo-server",
Image = "ubuntu-24.04",
Region = "melbourne",
Size = "std-min",
PortBlocking = false,
};
string json = JsonSerializer.Serialize(request, Json);
using JsonDocument document = JsonDocument.Parse(json);
Assert.Equal("demo-server", document.RootElement.GetProperty("name").GetString());
Assert.Equal("ubuntu-24.04", document.RootElement.GetProperty("image").GetString());
Assert.Equal("melbourne", document.RootElement.GetProperty("region").GetString());
Assert.Equal("std-min", document.RootElement.GetProperty("size").GetString());
Assert.False(document.RootElement.GetProperty("port_blocking").GetBoolean());
Assert.False(document.RootElement.TryGetProperty("password", out _));
Assert.False(document.RootElement.TryGetProperty("user_data", out _));
}
[Fact]
public void UnknownResponsePropertiesAreRetainedForForwardCompatibility()
{
const string json = """
{
"email": "alex@example.test",
"email_verified": true,
"two_factor_authentication_enabled": false,
"status": "active",
"tax_code": { "name": "GST", "type": "scalar" },
"configured_payment_methods": ["credit-card"],
"additional_ipv4_limit": 2,
"future_provider_field": "kept"
}
""";
Account? account = JsonSerializer.Deserialize<Account>(json, Json);
Assert.NotNull(account);
Assert.Equal("active", account.Status);
Assert.NotNull(account.AdditionalProperties);
Assert.Equal(
"kept",
account.AdditionalProperties!["future_provider_field"].GetString());
}
[Fact]
public void PreviewEnumValuesRemainRawStrings()
{
const string json = """
{
"id": 42,
"status": "future-status",
"type": "future-action",
"started_at": "2026-08-16T12:34:56Z",
"title": "Future action",
"reason": "A future provider action",
"progress": { "percent_complete": 0, "completed_steps": [] }
}
""";
BinaryLaneAction? action = JsonSerializer.Deserialize<BinaryLaneAction>(json, Json);
Assert.NotNull(action);
Assert.Equal("future-status", action.Status);
Assert.Equal("future-action", action.Type);
}
[Fact]
public void DocumentedValuesAreAvailableWithoutClosedEnums()
{
Assert.Equal("completed", BinaryLaneValues.ActionStatus.Completed);
Assert.Equal("power_on", BinaryLaneValues.ServerActionType.PowerOn);
Assert.Equal("AAAA", BinaryLaneValues.DomainRecordType.Aaaa);
}
[Fact]
public void ComplexServerActionUsesTheSharedEndpointDiscriminatorAndWireNames()
{
ServerAction action = new ChangeVpcIpv4ServerAction
{
CurrentIpv4Address = "10.0.0.10",
NewIpv4Address = "10.0.0.20",
};
string json = JsonSerializer.Serialize(action, Json);
using JsonDocument document = JsonDocument.Parse(json);
Assert.Equal("change_vpc_ipv4", document.RootElement.GetProperty("type").GetString());
Assert.Equal("10.0.0.10", document.RootElement.GetProperty("current_ipv4_address").GetString());
Assert.Equal("10.0.0.20", document.RootElement.GetProperty("new_ipv4_address").GetString());
}
[Fact]
public void ServerActionConverterDeserializesKnownAndUnknownProviderActions()
{
const string knownJson = """
{
"type": "change_vpc_ipv4",
"current_ipv4_address": "10.0.0.10",
"new_ipv4_address": "10.0.0.20"
}
""";
const string unknownJson = """
{
"type": "future_action",
"provider_option": "kept"
}
""";
ServerAction? known = JsonSerializer.Deserialize<ServerAction>(knownJson, Json);
ServerAction? unknown = JsonSerializer.Deserialize<ServerAction>(unknownJson, Json);
ChangeVpcIpv4ServerAction typed = Assert.IsType<ChangeVpcIpv4ServerAction>(known);
Assert.Equal("10.0.0.10", typed.CurrentIpv4Address);
Assert.Equal("10.0.0.20", typed.NewIpv4Address);
UnknownServerAction future = Assert.IsType<UnknownServerAction>(unknown);
Assert.Equal("future_action", future.Type);
Assert.NotNull(future.AdditionalProperties);
Assert.Equal("kept", future.AdditionalProperties!["provider_option"].GetString());
}
}
@@ -0,0 +1,240 @@
{
"version": 2,
"dependencies": {
"net8.0": {
"coverlet.collector": {
"type": "Direct",
"requested": "[6.0.2, )",
"resolved": "6.0.2",
"contentHash": "bJShQ6uWRTQ100ZeyiMqcFlhP7WJ+bCuabUs885dJiBEzMsJMSFr7BOyeCw4rgvQokteGi5rKQTlkhfQPUXg2A=="
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Direct",
"requested": "[8.0.1, )",
"resolved": "8.0.1",
"contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
}
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[17.11.1, )",
"resolved": "17.11.1",
"contentHash": "U3Ty4BaGoEu+T2bwSko9tWqWUOU16WzSFkq6U8zve75oRBMSLTBdMAZrVNNz1Tq12aCdDom9fcOcM9QZaFHqFg==",
"dependencies": {
"Microsoft.CodeCoverage": "17.11.1",
"Microsoft.TestPlatform.TestHost": "17.11.1"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.2, )",
"resolved": "2.9.2",
"contentHash": "7LhFS2N9Z6Xgg8aE5lY95cneYivRMfRI8v+4PATa4S64D5Z/Plkg0qa8dTRHSiGRgVZ/CL2gEfJDE5AUhOX+2Q==",
"dependencies": {
"xunit.analyzers": "1.16.0",
"xunit.assert": "2.9.2",
"xunit.core": "[2.9.2]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[2.8.2, )",
"resolved": "2.8.2",
"contentHash": "vm1tbfXhFmjFMUmS4M0J0ASXz3/U5XvXBa6DOQUL3fEz4Vt6YPhv+ESCarx6M6D+9kJkJYZKCNvJMas1+nVfmQ=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "17.11.1",
"contentHash": "nPJqrcA5iX+Y0kqoT3a+pD/8lrW/V7ayqnEJQsTonSoPz59J8bmoQhcSN4G8+UJ64Hkuf0zuxnfuj2lkHOq4cA=="
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "doVPCUUCY7c6LhBsEfiy3W1bvS7Mi6LkfQMS8nlC22jZWNxBv8VO8bdfeyvpYFst6Kxqk7HBC6lytmEoBssvSQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "8.0.0",
"Microsoft.Extensions.Diagnostics.Abstractions": "8.0.1",
"Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0"
}
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "elH2vmwNmsXuKmUeMQ4YW9ldXiF+gSGDgg1vORksob5POnpaI6caj1Hu8zaYbEuibhqCoWg0YRWDazBY3zjBfg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2",
"Microsoft.Extensions.Options": "8.0.2"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "8.0.1",
"Microsoft.Extensions.Logging.Abstractions": "8.0.2",
"Microsoft.Extensions.Options": "8.0.2"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "8.0.2",
"contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "17.11.1",
"contentHash": "E2jZqAU6JeWEVsyOEOrSW1o1bpHLgb25ypvKNB/moBXPVsFYBPd/Jwi7OrYahG50J83LfHzezYI+GaEkpAotiA==",
"dependencies": {
"System.Reflection.Metadata": "1.6.0"
}
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "17.11.1",
"contentHash": "DnG+GOqJXO/CkoqlJWeDFTgPhqD/V6VqUIL3vINizCWZ3X+HshCtbbyDdSHQQEjrc2Sl/K3yaxX6s+5LFEdYuw==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "17.11.1",
"Newtonsoft.Json": "13.0.1"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.1",
"contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A=="
},
"System.Reflection.Metadata": {
"type": "Transitive",
"resolved": "1.6.0",
"contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ=="
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.16.0",
"contentHash": "hptYM7vGr46GUIgZt21YHO4rfuBAQS2eINbFo16CV/Dqq+24Tp+P5gDCACu1AbFfW4Sp/WRfDPSK8fmUUb8s0Q=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.2",
"contentHash": "QkNBAQG4pa66cholm28AxijBjrmki98/vsEh4Sx5iplzotvPgpiotcxqJQMRC8d7RV7nIT8ozh97957hDnZwsQ=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.2",
"contentHash": "O6RrNSdmZ0xgEn5kT927PNwog5vxTtKrWMihhhrT0Sg9jQ7iBDciYOwzBgP2krBEk5/GBXI18R1lKvmnxGcb4w==",
"dependencies": {
"xunit.extensibility.core": "[2.9.2]",
"xunit.extensibility.execution": "[2.9.2]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.2",
"contentHash": "Ol+KlBJz1x8BrdnhN2DeOuLrr1I/cTwtHCggL9BvYqFuVd/TUSzxNT5O0NxCIXth30bsKxgMfdqLTcORtM52yQ==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.2",
"contentHash": "rKMpq4GsIUIJibXuZoZ8lYp5EpROlnYaRpwu9Zr0sRZXE7JqJfEEbCsUriZqB+ByXCLFBJyjkTRULMdC+U566g==",
"dependencies": {
"xunit.extensibility.core": "[2.9.2]"
}
},
"binarylane.api": {
"type": "Project",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "[8.0.2, )",
"Microsoft.Extensions.Http": "[8.0.1, )",
"Microsoft.Extensions.Options": "[8.0.2, )"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "CentralTransitive",
"requested": "[8.0.2, )",
"resolved": "8.0.0",
"contentHash": "mBMoXLsr5s1y2zOHWmKsE9veDcx8h1x/c3rz4baEdQKTeDcmQAPNbB54Pi/lhFO3K431eEq6PFbMgLaa6PHFfA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "CentralTransitive",
"requested": "[8.0.2, )",
"resolved": "8.0.2",
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
},
"Microsoft.Extensions.Http": {
"type": "CentralTransitive",
"requested": "[8.0.1, )",
"resolved": "8.0.1",
"contentHash": "kDYeKJUzh0qeg/AI+nSr3ffthmXYQTEb0nS9qRC7YhSbbuN4M4NPbaB77AJwtkTnCV9XZ7qYj3dkZaNcyl73EA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2",
"Microsoft.Extensions.Diagnostics": "8.0.1",
"Microsoft.Extensions.Logging": "8.0.1",
"Microsoft.Extensions.Logging.Abstractions": "8.0.2",
"Microsoft.Extensions.Options": "8.0.2"
}
},
"Microsoft.Extensions.Options": {
"type": "CentralTransitive",
"requested": "[8.0.2, )",
"resolved": "8.0.2",
"contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "CentralTransitive",
"requested": "[8.0.0, )",
"resolved": "8.0.0",
"contentHash": "0f4DMRqEd50zQh+UyJc+/HiBsZ3vhAQALgdkcQEalSH1L2isdC7Yj54M3cyo5e+BeO5fcBQ7Dxly8XiBBcvRgw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
"Microsoft.Extensions.Configuration.Binder": "8.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
}
}
}
}
}