Skip to content

Commit 954d502

Browse files
Fix JSON-RPC ID round-trip preservation for numeric IDs
Co-authored-by: brandonh-msft <20270743+brandonh-msft@users.noreply.github.com>
1 parent 5f79a78 commit 954d502

File tree

10 files changed

+463
-132
lines changed

10 files changed

+463
-132
lines changed

src/A2A.AspNetCore/A2AJsonRpcProcessor.cs

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ internal static async Task<IResult> ProcessRequestAsync(ITaskManager taskManager
4040
{
4141
rpcRequest = (JsonRpcRequest?)await JsonSerializer.DeserializeAsync(request.Body, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcRequest)), cancellationToken).ConfigureAwait(false);
4242

43-
activity?.AddTag("request.id", rpcRequest!.Id);
43+
activity?.AddTag("request.id", rpcRequest!.Id.ToString());
4444
activity?.AddTag("request.method", rpcRequest!.Method);
4545

4646
// Dispatch based on return type
@@ -51,15 +51,17 @@ internal static async Task<IResult> ProcessRequestAsync(ITaskManager taskManager
5151

5252
return await SingleResponseAsync(taskManager, rpcRequest.Id, rpcRequest.Method, rpcRequest.Params, cancellationToken).ConfigureAwait(false);
5353
}
54-
catch (A2AException ex)
55-
{
56-
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
57-
return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcErrorResponse(rpcRequest?.Id ?? ex.GetRequestId(), ex));
58-
}
59-
catch (Exception ex)
60-
{
61-
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
62-
return new JsonRpcResponseResult(JsonRpcResponse.InternalErrorResponse(rpcRequest?.Id, ex.Message));
54+
catch (A2AException ex)
55+
{
56+
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
57+
var errorId = rpcRequest?.Id ?? new JsonRpcId(ex.GetRequestId());
58+
return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcErrorResponse(errorId, ex));
59+
}
60+
catch (Exception ex)
61+
{
62+
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
63+
var errorId = rpcRequest?.Id ?? new JsonRpcId((string?)null);
64+
return new JsonRpcResponseResult(JsonRpcResponse.InternalErrorResponse(errorId, ex.Message));
6365
}
6466
}
6567

@@ -76,10 +78,10 @@ internal static async Task<IResult> ProcessRequestAsync(ITaskManager taskManager
7678
/// <param name="parameters">The JSON parameters for the method call.</param>
7779
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
7880
/// <returns>A JSON-RPC response result containing the operation result or error.</returns>
79-
internal static async Task<JsonRpcResponseResult> SingleResponseAsync(ITaskManager taskManager, string? requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
81+
internal static async Task<JsonRpcResponseResult> SingleResponseAsync(ITaskManager taskManager, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
8082
{
81-
using var activity = ActivitySource.StartActivity($"SingleResponse/{method}", ActivityKind.Server);
82-
activity?.SetTag("request.id", requestId);
83+
using var activity = ActivitySource.StartActivity($"SingleResponse/{method}", ActivityKind.Server);
84+
activity?.SetTag("request.id", requestId.ToString());
8385
activity?.SetTag("request.method", method);
8486

8587
JsonRpcResponse? response = null;
@@ -165,10 +167,10 @@ private static T DeserializeAndValidate<T>(JsonElement jsonParamValue) where T :
165167
/// <param name="parameters">The JSON parameters for the streaming method call.</param>
166168
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
167169
/// <returns>An HTTP result that streams JSON-RPC responses as Server-Sent Events or an error response.</returns>
168-
internal static IResult StreamResponse(ITaskManager taskManager, string? requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
170+
internal static IResult StreamResponse(ITaskManager taskManager, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
169171
{
170-
using var activity = ActivitySource.StartActivity("StreamResponse", ActivityKind.Server);
171-
activity?.SetTag("request.id", requestId);
172+
using var activity = ActivitySource.StartActivity("StreamResponse", ActivityKind.Server);
173+
activity?.SetTag("request.id", requestId.ToString());
172174

173175
if (parameters == null)
174176
{

src/A2A.AspNetCore/JsonRpcStreamedResult.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ namespace A2A.AspNetCore;
1515
public class JsonRpcStreamedResult : IResult
1616
{
1717
private readonly IAsyncEnumerable<A2AEvent> _events;
18-
private readonly string? requestId;
18+
private readonly JsonRpcId requestId;
1919

2020
/// <summary>
2121
/// Initializes a new instance of the JsonRpcStreamedResult class.
2222
/// </summary>
2323
/// <param name="events">The async enumerable stream of A2A events to send as Server-Sent Events.</param>
2424
/// <param name="requestId">The JSON-RPC request ID used for correlating responses with the original request.</param>
25-
public JsonRpcStreamedResult(IAsyncEnumerable<A2AEvent> events, string? requestId)
25+
public JsonRpcStreamedResult(IAsyncEnumerable<A2AEvent> events, JsonRpcId requestId)
2626
{
2727
ArgumentNullException.ThrowIfNull(events);
2828

src/A2A/A2AExceptionExtensions.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,35 @@ public static A2AException WithRequestId(this A2AException exception, string? re
3030
return exception;
3131
}
3232

33+
/// <summary>
34+
/// Associates a request ID with the specified <see cref="A2AException"/>.
35+
/// </summary>
36+
/// <param name="exception">The <see cref="A2AException"/> to associate the request ID with.</param>
37+
/// <param name="requestId">The request ID to associate with the exception.</param>
38+
/// <returns>The same <see cref="A2AException"/> instance with the request ID stored in its Data collection.</returns>
39+
/// <remarks>
40+
/// This method stores the request ID in the exception's Data collection using the key "RequestId".
41+
/// The request ID can be later retrieved using the <see cref="GetRequestId"/> method.
42+
/// This is useful for correlating exceptions with specific HTTP requests in logging and debugging scenarios.
43+
/// </remarks>
44+
public static A2AException WithRequestId(this A2AException exception, JsonRpcId requestId)
45+
{
46+
if (exception is null)
47+
{
48+
throw new ArgumentNullException(nameof(exception));
49+
}
50+
51+
exception.Data[RequestIdKey] = requestId.ToString();
52+
53+
return exception;
54+
}
55+
3356
/// <summary>
3457
/// Retrieves the request ID associated with the specified <see cref="A2AException"/>.
3558
/// </summary>
3659
/// <param name="exception">The <see cref="A2AException"/> to retrieve the request ID from.</param>
3760
/// <returns>
38-
/// The request ID associated with the exception if one was previously set using <see cref="WithRequestId"/>,
61+
/// The request ID associated with the exception if one was previously set using <see cref="WithRequestId(A2AException, string?)"/>,
3962
/// or null if no request ID was set or if the stored value is not a string.
4063
/// </returns>
4164
/// <remarks>

src/A2A/A2AJsonUtilities.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public static partial class A2AJsonUtilities
3535

3636
// JSON-RPC
3737
[JsonSerializable(typeof(JsonRpcError))]
38+
[JsonSerializable(typeof(JsonRpcId))]
3839
[JsonSerializable(typeof(JsonRpcRequest))]
3940
[JsonSerializable(typeof(JsonRpcResponse))]
4041
[JsonSerializable(typeof(Dictionary<string, JsonElement>))]

src/A2A/JsonRpc/JsonRpcId.cs

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using System.Text.Json;
3+
using System.Text.Json.Serialization;
4+
5+
namespace A2A;
6+
7+
/// <summary>
8+
/// Represents a JSON-RPC ID that can be either a string or a number, preserving the original type.
9+
/// </summary>
10+
[JsonConverter(typeof(JsonRpcIdConverter))]
11+
public readonly struct JsonRpcId : IEquatable<JsonRpcId>
12+
{
13+
private readonly object? _value;
14+
15+
/// <summary>
16+
/// Initializes a new instance of the <see cref="JsonRpcId"/> struct with a string value.
17+
/// </summary>
18+
/// <param name="value">The string value.</param>
19+
public JsonRpcId(string? value)
20+
{
21+
_value = value;
22+
}
23+
24+
/// <summary>
25+
/// Initializes a new instance of the <see cref="JsonRpcId"/> struct with a numeric value.
26+
/// </summary>
27+
/// <param name="value">The numeric value.</param>
28+
public JsonRpcId(long value)
29+
{
30+
_value = value;
31+
}
32+
33+
/// <summary>
34+
/// Initializes a new instance of the <see cref="JsonRpcId"/> struct with a numeric value.
35+
/// </summary>
36+
/// <param name="value">The numeric value.</param>
37+
public JsonRpcId(int value)
38+
{
39+
_value = (long)value;
40+
}
41+
42+
/// <summary>
43+
/// Gets a value indicating whether this ID has a value.
44+
/// </summary>
45+
public bool HasValue => _value is not null;
46+
47+
/// <summary>
48+
/// Gets a value indicating whether this ID is a string.
49+
/// </summary>
50+
public bool IsString => _value is string;
51+
52+
/// <summary>
53+
/// Gets a value indicating whether this ID is a number.
54+
/// </summary>
55+
public bool IsNumber => _value is long;
56+
57+
/// <summary>
58+
/// Gets the string value of this ID if it's a string.
59+
/// </summary>
60+
/// <returns>The string value, or null if not a string.</returns>
61+
public string? AsString() => _value as string;
62+
63+
/// <summary>
64+
/// Gets the numeric value of this ID if it's a number.
65+
/// </summary>
66+
/// <returns>The numeric value, or null if not a number.</returns>
67+
public long? AsNumber() => _value as long?;
68+
69+
/// <summary>
70+
/// Gets the raw value as an object.
71+
/// </summary>
72+
/// <returns>The raw value as an object.</returns>
73+
public object? AsObject() => _value;
74+
75+
/// <summary>
76+
/// Returns a string representation of this ID.
77+
/// </summary>
78+
/// <returns>A string representation of the ID.</returns>
79+
public override string? ToString() => _value?.ToString();
80+
81+
/// <summary>
82+
/// Determines whether the specified object is equal to the current ID.
83+
/// </summary>
84+
/// <param name="obj">The object to compare with the current ID.</param>
85+
/// <returns>true if the specified object is equal to the current ID; otherwise, false.</returns>
86+
public override bool Equals(object? obj) => obj is JsonRpcId other && Equals(other);
87+
88+
/// <summary>
89+
/// Determines whether the specified ID is equal to the current ID.
90+
/// </summary>
91+
/// <param name="other">The ID to compare with the current ID.</param>
92+
/// <returns>true if the specified ID is equal to the current ID; otherwise, false.</returns>
93+
public bool Equals(JsonRpcId other) => Equals(_value, other._value);
94+
95+
/// <summary>
96+
/// Returns the hash code for this ID.
97+
/// </summary>
98+
/// <returns>A hash code for the current ID.</returns>
99+
public override int GetHashCode() => _value?.GetHashCode() ?? 0;
100+
101+
/// <summary>
102+
/// Determines whether two IDs are equal.
103+
/// </summary>
104+
/// <param name="left">The first ID to compare.</param>
105+
/// <param name="right">The second ID to compare.</param>
106+
/// <returns>true if the IDs are equal; otherwise, false.</returns>
107+
public static bool operator ==(JsonRpcId left, JsonRpcId right) => left.Equals(right);
108+
109+
/// <summary>
110+
/// Determines whether two IDs are not equal.
111+
/// </summary>
112+
/// <param name="left">The first ID to compare.</param>
113+
/// <param name="right">The second ID to compare.</param>
114+
/// <returns>true if the IDs are not equal; otherwise, false.</returns>
115+
public static bool operator !=(JsonRpcId left, JsonRpcId right) => !left.Equals(right);
116+
117+
/// <summary>
118+
/// Implicitly converts a string to a JsonRpcId.
119+
/// </summary>
120+
/// <param name="value">The string value.</param>
121+
/// <returns>A JsonRpcId with the string value.</returns>
122+
public static implicit operator JsonRpcId(string? value) => new(value);
123+
124+
/// <summary>
125+
/// Implicitly converts a long to a JsonRpcId.
126+
/// </summary>
127+
/// <param name="value">The numeric value.</param>
128+
/// <returns>A JsonRpcId with the numeric value.</returns>
129+
public static implicit operator JsonRpcId(long value) => new(value);
130+
131+
/// <summary>
132+
/// Implicitly converts an int to a JsonRpcId.
133+
/// </summary>
134+
/// <param name="value">The numeric value.</param>
135+
/// <returns>A JsonRpcId with the numeric value.</returns>
136+
public static implicit operator JsonRpcId(int value) => new(value);
137+
}
138+
139+
/// <summary>
140+
/// JSON converter for JsonRpcId that preserves the original type during serialization/deserialization.
141+
/// </summary>
142+
internal sealed class JsonRpcIdConverter : JsonConverter<JsonRpcId>
143+
{
144+
public override JsonRpcId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
145+
{
146+
switch (reader.TokenType)
147+
{
148+
case JsonTokenType.String:
149+
return new JsonRpcId(reader.GetString());
150+
case JsonTokenType.Number:
151+
if (reader.TryGetInt64(out var longValue))
152+
{
153+
return new JsonRpcId(longValue);
154+
}
155+
throw new JsonException("Invalid numeric value for JSON-RPC ID.");
156+
case JsonTokenType.Null:
157+
return new JsonRpcId((string?)null);
158+
default:
159+
throw new JsonException("Invalid JSON-RPC ID format. Must be string, number, or null.");
160+
}
161+
}
162+
163+
public override void Write(Utf8JsonWriter writer, JsonRpcId value, JsonSerializerOptions options)
164+
{
165+
if (!value.HasValue)
166+
{
167+
writer.WriteNullValue();
168+
}
169+
else if (value.IsString)
170+
{
171+
writer.WriteStringValue(value.AsString());
172+
}
173+
else if (value.IsNumber)
174+
{
175+
writer.WriteNumberValue(value.AsNumber()!.Value);
176+
}
177+
else
178+
{
179+
writer.WriteNullValue();
180+
}
181+
}
182+
}

src/A2A/JsonRpc/JsonRpcRequest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public sealed class JsonRpcRequest
2626
/// Numbers SHOULD NOT contain fractional parts.
2727
/// </remarks>
2828
[JsonPropertyName("id")]
29-
public string? Id { get; set; }
29+
public JsonRpcId Id { get; set; }
3030

3131
/// <summary>
3232
/// Gets or sets the string containing the name of the method to be invoked.

src/A2A/JsonRpc/JsonRpcRequestConverter.cs

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ internal sealed class JsonRpcRequestConverter : JsonConverter<JsonRpcRequest>
2525
var rootElement = jsonDoc.RootElement;
2626

2727
// Validate the JSON-RPC request structure
28-
requestId = ReadAndValidateIdField(rootElement);
28+
var idField = ReadAndValidateIdField(rootElement);
29+
requestId = idField.ToString();
2930

3031
return new JsonRpcRequest
3132
{
32-
Id = requestId,
33+
Id = idField,
3334
JsonRpc = ReadAndValidateJsonRpcField(rootElement, requestId),
3435
Method = ReadAndValidateMethodField(rootElement, requestId),
3536
Params = ReadAndValidateParamsField(rootElement, requestId)
@@ -50,7 +51,25 @@ public override void Write(Utf8JsonWriter writer, JsonRpcRequest value, JsonSeri
5051

5152
writer.WriteStartObject();
5253
writer.WriteString("jsonrpc", value.JsonRpc);
53-
writer.WriteString("id", value.Id);
54+
55+
writer.WritePropertyName("id");
56+
if (!value.Id.HasValue)
57+
{
58+
writer.WriteNullValue();
59+
}
60+
else if (value.Id.IsString)
61+
{
62+
writer.WriteStringValue(value.Id.AsString());
63+
}
64+
else if (value.Id.IsNumber)
65+
{
66+
writer.WriteNumberValue(value.Id.AsNumber()!.Value);
67+
}
68+
else
69+
{
70+
writer.WriteNullValue();
71+
}
72+
5473
writer.WriteString("method", value.Method);
5574

5675
if (value.Params.HasValue)
@@ -66,8 +85,8 @@ public override void Write(Utf8JsonWriter writer, JsonRpcRequest value, JsonSeri
6685
/// Reads and validates the 'id' field of a JSON-RPC request.
6786
/// </summary>
6887
/// <param name="rootElement">The root JSON element containing the request.</param>
69-
/// <returns>The extracted request ID as a string, or null if not present.</returns>
70-
private static string? ReadAndValidateIdField(JsonElement rootElement)
88+
/// <returns>The extracted request ID as a JsonRpcId.</returns>
89+
private static JsonRpcId ReadAndValidateIdField(JsonElement rootElement)
7190
{
7291
if (rootElement.TryGetProperty("id", out var idElement))
7392
{
@@ -78,10 +97,16 @@ public override void Write(Utf8JsonWriter writer, JsonRpcRequest value, JsonSeri
7897
throw new A2AException("Invalid JSON-RPC request: 'id' field must be a string, number, or null.", A2AErrorCode.InvalidRequest);
7998
}
8099

81-
return idElement.ValueKind == JsonValueKind.Null ? null : idElement.ToString();
100+
return idElement.ValueKind switch
101+
{
102+
JsonValueKind.Null => new JsonRpcId((string?)null),
103+
JsonValueKind.String => new JsonRpcId(idElement.GetString()),
104+
JsonValueKind.Number => new JsonRpcId(idElement.GetInt64()),
105+
_ => new JsonRpcId((string?)null)
106+
};
82107
}
83108

84-
return null;
109+
return new JsonRpcId((string?)null);
85110
}
86111

87112
/// <summary>

0 commit comments

Comments
 (0)