diff --git a/CHANGELOG.md b/CHANGELOG.md index a1d766d..bbe192f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Services/Types.cs b/Services/Types.cs index 8824186..f3c62ce 100644 --- a/Services/Types.cs +++ b/Services/Types.cs @@ -2,6 +2,7 @@ namespace KeyMint.Services { using System; using System.Collections.Generic; + using System.Text.Json; using System.Text.Json.Serialization; /// @@ -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 } /// @@ -138,6 +140,41 @@ public class KeyMintApiErrorDetails public object? Details { get; set; } } + /// + /// Reads the API's error field, which is either an object + /// ({ code, message, details }) or a plain string (e.g. "Method not allowed"). + /// A string is kept as . + /// + public class KeyMintApiErrorDetailsConverter : JsonConverter + { + 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(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); + } + } + /// /// Parameters for the activateKey API endpoint. /// diff --git a/Tests/ApiContractTests.cs b/Tests/ApiContractTests.cs index dfd113c..cb3dc92 100644 --- a/Tests/ApiContractTests.cs +++ b/Tests/ApiContractTests.cs @@ -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(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(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)