Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Fixed

- `KeyMintApiError.Error` now deserialises when the API sends `error` as a plain string (for example the 405 "Method not allowed" and the IP-allowlist 403) as well as an object. A string is kept in `Error.Message`; before, these responses failed to parse and returned a generic `HTTP error: ...` with `Code = -1`.

## 1.7.0 - 2026-10-26

### Added
Expand Down
39 changes: 38 additions & 1 deletion Services/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ namespace KeyMint.Services
{
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

/// <summary>
Expand Down Expand Up @@ -122,7 +123,8 @@ public class KeyMintApiError
[JsonPropertyName("status")]
public int? Status { get; set; } // HTTP status code, optional
[JsonPropertyName("error")]
public KeyMintApiErrorDetails? Error { get; set; }
[JsonConverter(typeof(KeyMintApiErrorDetailsConverter))]
public KeyMintApiErrorDetails? Error { get; set; } // Object envelope, or a plain string kept in Message
}

/// <summary>
Expand All @@ -138,6 +140,41 @@ public class KeyMintApiErrorDetails
public object? Details { get; set; }
}

/// <summary>
/// Reads the API's <c>error</c> field, which is either an object
/// (<c>{ code, message, details }</c>) or a plain string (e.g. "Method not allowed").
/// A string is kept as <see cref="KeyMintApiErrorDetails.Message"/>.
/// </summary>
public class KeyMintApiErrorDetailsConverter : JsonConverter<KeyMintApiErrorDetails?>
{
public override bool HandleNull => true;

public override KeyMintApiErrorDetails? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.Null:
return null;
case JsonTokenType.String:
return new KeyMintApiErrorDetails { Message = reader.GetString() };
case JsonTokenType.StartObject:
return JsonSerializer.Deserialize<KeyMintApiErrorDetails>(ref reader, options);
default:
throw new JsonException($"Unexpected token {reader.TokenType} for the API error field.");
}
}

public override void Write(Utf8JsonWriter writer, KeyMintApiErrorDetails? value, JsonSerializerOptions options)
{
if (value == null)
{
writer.WriteNullValue();
return;
}
JsonSerializer.Serialize(writer, value, options);
}
}

/// <summary>
/// Parameters for the activateKey API endpoint.
/// </summary>
Expand Down
47 changes: 47 additions & 0 deletions Tests/ApiContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,53 @@ public async Task Api_errors_expose_the_current_nested_error_message()
Assert.Equal(409, result.Error.Status);
}

[Fact]
public async Task Api_errors_accept_a_string_error_field()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.Forbidden)
{
Content = new StringContent(
"{\"error\":\"This API key is not allowed from this IP address\",\"message\":\"This API key is not allowed from this IP address\",\"code\":1}",
Encoding.UTF8,
"application/json")
});
var sdk = CreateSdk(handler);

var result = await sdk.ActivateKey(new ActivateKeyParams { ProductId = "product_123", LicenseKey = "key_123" });

Assert.False(result.IsSuccess);
Assert.Equal("This API key is not allowed from this IP address", result.Error!.Message);
Assert.Equal(1, result.Error.Code);
Assert.Equal(403, result.Error.Status);
Assert.Equal("This API key is not allowed from this IP address", result.Error.Error!.Message);
Assert.Null(result.Error.Error.Code);
}

[Theory]
[InlineData("{\"message\":\"Method not allowed\",\"code\":1,\"error\":\"Method not allowed\"}", null, "Method not allowed")]
[InlineData("{\"message\":\"Too many requests\",\"code\":1,\"error\":{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"Too many requests\"}}", "RATE_LIMIT_EXCEEDED", "Too many requests")]
public void Api_error_field_deserializes_from_string_or_object(string json, string? expectedCode, string expectedMessage)
{
var error = JsonSerializer.Deserialize<KeyMintApiError>(json)!;

Assert.Equal(1, error.Code);
Assert.NotNull(error.Error);
Assert.Equal(expectedCode, error.Error!.Code);
Assert.Equal(expectedMessage, error.Error.Message);
}

[Theory]
[InlineData("{\"message\":\"Invalid license key\",\"code\":1}")]
[InlineData("{\"message\":\"Invalid license key\",\"code\":1,\"error\":null}")]
public void Api_error_without_error_field_deserializes(string json)
{
var error = JsonSerializer.Deserialize<KeyMintApiError>(json)!;

Assert.Equal("Invalid license key", error.Message);
Assert.Equal(1, error.Code);
Assert.Null(error.Error);
}

private static KeyMintSDK CreateSdk(HttpMessageHandler handler)
{
return new KeyMintSDK("admin_test", new HttpClient(handler)
Expand Down
Loading