From c9c4f746d1c2f1f6294ae250b8de55a774a10901 Mon Sep 17 00:00:00 2001 From: Wi1l-B0t <201105916+Wi1l-B0t@users.noreply.github.com> Date: Sat, 19 Jul 2025 15:33:41 +0800 Subject: [PATCH] Optimize: merge RpcMethod and RpcMethodWithParams --- .../RpcServer/RpcMethodWithParamsAttribute.cs | 24 --- src/Plugins/RpcServer/RpcServer.Blockchain.cs | 32 +-- src/Plugins/RpcServer/RpcServer.Node.cs | 10 +- src/Plugins/RpcServer/RpcServer.cs | 186 +++++++++--------- .../UT_RpcServer.cs | 54 ++++- 5 files changed, 157 insertions(+), 149 deletions(-) delete mode 100644 src/Plugins/RpcServer/RpcMethodWithParamsAttribute.cs diff --git a/src/Plugins/RpcServer/RpcMethodWithParamsAttribute.cs b/src/Plugins/RpcServer/RpcMethodWithParamsAttribute.cs deleted file mode 100644 index 6e99b43a8c..0000000000 --- a/src/Plugins/RpcServer/RpcMethodWithParamsAttribute.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (C) 2015-2025 The Neo Project. -// -// RpcMethodWithParamsAttribute.cs file belongs to the neo project and is free -// software distributed under the MIT software license, see the -// accompanying file LICENSE in the main directory of the -// repository or http://www.opensource.org/licenses/mit-license.php -// for more details. -// -// Redistribution and use in source and binary forms with or without -// modifications are permitted. - -using System; - -namespace Neo.Plugins.RpcServer -{ - /// - /// Indicates that the method is an RPC method with parameters. - /// - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] - public class RpcMethodWithParamsAttribute : Attribute - { - public string Name { get; set; } - } -} diff --git a/src/Plugins/RpcServer/RpcServer.Blockchain.cs b/src/Plugins/RpcServer/RpcServer.Blockchain.cs index a8b6fa2a42..fb7f9af5c2 100644 --- a/src/Plugins/RpcServer/RpcServer.Blockchain.cs +++ b/src/Plugins/RpcServer/RpcServer.Blockchain.cs @@ -37,7 +37,7 @@ partial class RpcServer /// /// /// The hash of the best block as a . - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetBestBlockHash() { return NativeContract.Ledger.CurrentHash(system.StoreView).ToString(); @@ -88,7 +88,7 @@ protected internal virtual JToken GetBestBlockHash() /// Optional, the default value is false. /// The block data as a . If the second item of _params is true, then /// block data is json format, otherwise, the return type is Base64-encoded byte array. - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetBlock(BlockHashOrIndex blockHashOrIndex, bool verbose = false) { RpcException.ThrowIfNull(blockHashOrIndex, nameof(blockHashOrIndex), RpcError.InvalidParams); @@ -118,7 +118,7 @@ protected internal virtual JToken GetBlock(BlockHashOrIndex blockHashOrIndex, bo /// {"jsonrpc": "2.0", "id": 1, "result": 100 /* The number of block headers in the blockchain */} /// /// The count of block headers as a . - [RpcMethodWithParams] + [RpcMethod] internal virtual JToken GetBlockHeaderCount() { return (system.HeaderCache.Last?.Index ?? NativeContract.Ledger.CurrentIndex(system.StoreView)) + 1; @@ -132,7 +132,7 @@ internal virtual JToken GetBlockHeaderCount() /// {"jsonrpc": "2.0", "id": 1, "result": 100 /* The number of blocks in the blockchain */} /// /// The count of blocks as a . - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetBlockCount() { return NativeContract.Ledger.CurrentIndex(system.StoreView) + 1; @@ -149,7 +149,7 @@ protected internal virtual JToken GetBlockCount() /// /// Block index (block height) /// The hash of the block at the specified height as a . - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetBlockHash(uint height) { var snapshot = system.StoreView; @@ -209,7 +209,7 @@ protected internal virtual JToken GetBlockHash(uint height) /// The block header data as a . /// In json format if the second item of _params is true, otherwise Base64-encoded byte array. /// - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetBlockHeader(BlockHashOrIndex blockHashOrIndex, bool verbose = false) { RpcException.ThrowIfNull(blockHashOrIndex, nameof(blockHashOrIndex), RpcError.InvalidParams); @@ -249,7 +249,7 @@ protected internal virtual JToken GetBlockHeader(BlockHashOrIndex blockHashOrInd /// /// Contract name or script hash or the native contract id. /// The contract state in json format as a . - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetContractState(ContractNameOrHashOrId contractNameOrHashOrId) { RpcException.ThrowIfNull(contractNameOrHashOrId, nameof(contractNameOrHashOrId), RpcError.InvalidParams); @@ -300,7 +300,7 @@ private static UInt160 ToScriptHash(string keyword) /// /// Optional, the default value is false. /// The memory pool transactions in json format as a . - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetRawMemPool(bool shouldGetUnverified = false) { if (!shouldGetUnverified) @@ -355,7 +355,7 @@ protected internal virtual JToken GetRawMemPool(bool shouldGetUnverified = false /// The transaction hash. /// Optional, the default value is false. /// The transaction data as a . In json format if verbose is true, otherwise base64string. - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetRawTransaction(UInt256 hash, bool verbose = false) { RpcException.ThrowIfNull(hash, nameof(hash), RpcError.InvalidParams); @@ -406,7 +406,7 @@ private static int GetContractId(IReadOnlyStore snapshot, ContractNameOrHashOrId /// The contract ID or script hash. /// The Base64-encoded storage key. /// The storage item as a . - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetStorage(ContractNameOrHashOrId contractNameOrHashOrId, string base64Key) { RpcException.ThrowIfNull(contractNameOrHashOrId, nameof(contractNameOrHashOrId), RpcError.InvalidParams); @@ -456,7 +456,7 @@ protected internal virtual JToken GetStorage(ContractNameOrHashOrId contractName /// The Base64-encoded storage key prefix. /// The start index. /// The found storage items as a . - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken FindStorage(ContractNameOrHashOrId contractNameOrHashOrId, string base64KeyPrefix, int start = 0) { RpcException.ThrowIfNull(contractNameOrHashOrId, nameof(contractNameOrHashOrId), RpcError.InvalidParams); @@ -509,7 +509,7 @@ protected internal virtual JToken FindStorage(ContractNameOrHashOrId contractNam /// /// The transaction hash. /// The height of the transaction as a . - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetTransactionHeight(UInt256 hash) { RpcException.ThrowIfNull(hash, nameof(hash), RpcError.InvalidParams); @@ -534,7 +534,7 @@ protected internal virtual JToken GetTransactionHeight(UInt256 hash) /// } /// /// The next block validators as a . - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetNextBlockValidators() { using var snapshot = system.GetSnapshotCache(); @@ -563,7 +563,7 @@ protected internal virtual JToken GetNextBlockValidators() /// } /// /// The candidates public key list as a JToken. - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetCandidates() { using var snapshot = system.GetSnapshotCache(); @@ -624,7 +624,7 @@ protected internal virtual JToken GetCandidates() /// {"jsonrpc": "2.0", "id": 1, "result": ["The public key"]} /// /// The committee members publickeys as a . - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetCommittee() { return new JArray(NativeContract.NEO.GetCommittee(system.StoreView).Select(p => (JToken)p.ToString())); @@ -711,7 +711,7 @@ protected internal virtual JToken GetCommittee() /// } /// /// The native contract states as a . - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetNativeContracts() { var storeView = system.StoreView; diff --git a/src/Plugins/RpcServer/RpcServer.Node.cs b/src/Plugins/RpcServer/RpcServer.Node.cs index 7d418dca0f..f87d586bb2 100644 --- a/src/Plugins/RpcServer/RpcServer.Node.cs +++ b/src/Plugins/RpcServer/RpcServer.Node.cs @@ -33,7 +33,7 @@ partial class RpcServer /// {"jsonrpc": "2.0", "id": 1, "result": 10} /// /// The number of connections as a JToken. - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetConnectionCount() { return localNode.ConnectedCount; @@ -56,7 +56,7 @@ protected internal virtual JToken GetConnectionCount() /// } /// /// A JObject containing information about unconnected, bad, and connected peers. - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetPeers() { return new JObject() @@ -146,7 +146,7 @@ private static JObject GetRelayResult(VerifyResult reason, UInt256 hash) /// } /// /// A JObject containing detailed version and configuration information. - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken GetVersion() { JObject json = new(); @@ -205,7 +205,7 @@ private static string StripPrefix(string s, string prefix) /// /// The base64-encoded transaction. /// A JToken containing the result of the transaction relay. - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken SendRawTransaction(string base64Tx) { var tx = Result.Ok_Or( @@ -225,7 +225,7 @@ protected internal virtual JToken SendRawTransaction(string base64Tx) /// /// The base64-encoded block. /// A JToken containing the result of the block submission. - [RpcMethodWithParams] + [RpcMethod] protected internal virtual JToken SubmitBlock(string base64Block) { var block = Result.Ok_Or( diff --git a/src/Plugins/RpcServer/RpcServer.cs b/src/Plugins/RpcServer/RpcServer.cs index 1929aa9ff9..63968ee5d2 100644 --- a/src/Plugins/RpcServer/RpcServer.cs +++ b/src/Plugins/RpcServer/RpcServer.cs @@ -38,9 +38,10 @@ namespace Neo.Plugins.RpcServer public partial class RpcServer : IDisposable { private const int MaxParamsDepth = 32; + private const string HttpMethodGet = "GET"; + private const string HttpMethodPost = "POST"; - private readonly Dictionary> methods = new(); - private readonly Dictionary _methodsWithParams = new(); + private readonly Dictionary _methods = new(); private IWebHost host; private RpcServersSettings settings; @@ -94,11 +95,10 @@ internal bool CheckAuth(HttpContext context) } int colonIndex = Array.IndexOf(auths, (byte)':'); - if (colonIndex == -1) - return false; + if (colonIndex == -1) return false; - byte[] user = auths[..colonIndex]; - byte[] pass = auths[(colonIndex + 1)..]; + var user = auths[..colonIndex]; + var pass = auths[(colonIndex + 1)..]; // Always execute both checks, but both must evaluate to true return CryptographicOperations.FixedTimeEquals(user, _rpcUser) & CryptographicOperations.FixedTimeEquals(pass, _rpcPass); @@ -106,17 +106,18 @@ internal bool CheckAuth(HttpContext context) private static JObject CreateErrorResponse(JToken id, RpcError rpcError) { - JObject response = CreateResponse(id); + var response = CreateResponse(id); response["error"] = rpcError.ToJson(); return response; } private static JObject CreateResponse(JToken id) { - JObject response = new(); - response["jsonrpc"] = "2.0"; - response["id"] = id; - return response; + return new JObject + { + ["jsonrpc"] = "2.0", + ["id"] = id + }; } /// @@ -168,23 +169,19 @@ public void StartRpcServer() if (string.IsNullOrEmpty(settings.SslCert)) return; listenOptions.UseHttps(settings.SslCert, settings.SslCertPassword, httpsConnectionAdapterOptions => { - if (settings.TrustedAuthorities is null || settings.TrustedAuthorities.Length == 0) - return; + if (settings.TrustedAuthorities is null || settings.TrustedAuthorities.Length == 0) return; httpsConnectionAdapterOptions.ClientCertificateMode = ClientCertificateMode.RequireCertificate; httpsConnectionAdapterOptions.ClientCertificateValidation = (cert, chain, err) => { - if (err != SslPolicyErrors.None) - return false; - X509Certificate2 authority = chain.ChainElements[^1].Certificate; + if (err != SslPolicyErrors.None) return false; + var authority = chain.ChainElements[^1].Certificate; return settings.TrustedAuthorities.Contains(authority.Thumbprint); }; }); })) .Configure(app => { - if (settings.EnableCors) - app.UseCors("All"); - + if (settings.EnableCors) app.UseCors("All"); app.UseResponseCompression(); app.Run(ProcessAsync); }) @@ -193,28 +190,32 @@ public void StartRpcServer() if (settings.EnableCors) { if (settings.AllowOrigins.Length == 0) + { services.AddCors(options => { options.AddPolicy("All", policy => { policy.AllowAnyOrigin() - .WithHeaders("Content-Type") - .WithMethods("GET", "POST"); + .WithHeaders("Content-Type") + .WithMethods(HttpMethodGet, HttpMethodPost); // The CORS specification states that setting origins to "*" (all origins) // is invalid if the Access-Control-Allow-Credentials header is present. }); }); + } else + { services.AddCors(options => { options.AddPolicy("All", policy => { policy.WithOrigins(settings.AllowOrigins) - .WithHeaders("Content-Type") - .AllowCredentials() - .WithMethods("GET", "POST"); + .WithHeaders("Content-Type") + .AllowCredentials() + .WithMethods(HttpMethodGet, HttpMethodPost); }); }); + } } services.AddResponseCompression(options => @@ -241,9 +242,10 @@ internal void UpdateSettings(RpcServersSettings settings) public async Task ProcessAsync(HttpContext context) { - if (context.Request.Method != "GET" && context.Request.Method != "POST") return; + if (context.Request.Method != HttpMethodGet && context.Request.Method != HttpMethodPost) return; + JToken request = null; - if (context.Request.Method == "GET") + if (context.Request.Method == HttpMethodGet) { string jsonrpc = context.Request.Query["jsonrpc"]; string id = context.Request.Query["id"]; @@ -256,6 +258,7 @@ public async Task ProcessAsync(HttpContext context) _params = Encoding.UTF8.GetString(Convert.FromBase64String(_params)); } catch (FormatException) { } + request = new JObject(); if (!string.IsNullOrEmpty(jsonrpc)) request["jsonrpc"] = jsonrpc; @@ -264,15 +267,16 @@ public async Task ProcessAsync(HttpContext context) request["params"] = JToken.Parse(_params, MaxParamsDepth); } } - else if (context.Request.Method == "POST") + else if (context.Request.Method == HttpMethodPost) { - using StreamReader reader = new(context.Request.Body); + using var reader = new StreamReader(context.Request.Body); try { request = JToken.Parse(await reader.ReadToEndAsync(), MaxParamsDepth); } catch (FormatException) { } } + JToken response; if (request == null) { @@ -295,6 +299,7 @@ public async Task ProcessAsync(HttpContext context) { response = await ProcessRequestAsync(context, (JObject)request); } + if (response == null || (response as JArray)?.Count == 0) return; context.Response.ContentType = "application/json"; await context.Response.WriteAsync(response.ToString(), Encoding.UTF8); @@ -316,55 +321,9 @@ internal async Task ProcessRequestAsync(HttpContext context, JObject re var method = request["method"].AsString(); (CheckAuth(context) && !settings.DisabledMethods.Contains(method)).True_Or(RpcError.AccessDenied); - if (methods.TryGetValue(method, out var func)) + if (_methods.TryGetValue(method, out var func)) { - response["result"] = func(jsonParameters) switch - { - JToken result => result, - Task task => await task, - _ => throw new NotSupportedException() - }; - return response; - } - - if (_methodsWithParams.TryGetValue(method, out var func2)) - { - var paramInfos = func2.Method.GetParameters(); - var args = new object[paramInfos.Length]; - - for (var i = 0; i < paramInfos.Length; i++) - { - var param = paramInfos[i]; - if (jsonParameters.Count > i && jsonParameters[i] != null) - { - try - { - args[i] = ParameterConverter.AsParameter(jsonParameters[i], param.ParameterType); - } - catch (Exception e) when (e is not RpcException) - { - throw new ArgumentException($"Invalid value for parameter '{param.Name}'", e); - } - } - else - { - if (param.IsOptional) - { - args[i] = param.DefaultValue; - } - else if (param.ParameterType.IsValueType && - Nullable.GetUnderlyingType(param.ParameterType) == null) - { - throw new ArgumentException($"Required parameter '{param.Name}' is missing"); - } - else - { - args[i] = null; - } - } - } - - response["result"] = func2.DynamicInvoke(args) switch + response["result"] = ProcessParamsMethod(jsonParameters, func) switch { JToken result => result, Task task => await task, @@ -386,49 +345,82 @@ internal async Task ProcessRequestAsync(HttpContext context, JObject re catch (Exception ex) when (ex is not RpcException) { // Unwrap the exception to get the original error code - var unwrappedException = UnwrapException(ex); + var unwrapped = UnwrapException(ex); #if DEBUG return CreateErrorResponse(request["id"], - RpcErrorFactory.NewCustomError(unwrappedException.HResult, unwrappedException.Message, unwrappedException.StackTrace)); + RpcErrorFactory.NewCustomError(unwrapped.HResult, unwrapped.Message, unwrapped.StackTrace)); #else - return CreateErrorResponse(request["id"], RpcErrorFactory.NewCustomError(unwrappedException.HResult, unwrappedException.Message)); + return CreateErrorResponse(request["id"], RpcErrorFactory.NewCustomError(unwrapped.HResult, unwrapped.Message)); #endif } catch (RpcException ex) { #if DEBUG - return CreateErrorResponse(request["id"], - RpcErrorFactory.NewCustomError(ex.HResult, ex.Message, ex.StackTrace)); + return CreateErrorResponse(request["id"], RpcErrorFactory.NewCustomError(ex.HResult, ex.Message, ex.StackTrace)); #else return CreateErrorResponse(request["id"], ex.GetError()); #endif } } - public void RegisterMethods(object handler) + private object ProcessParamsMethod(JArray arguments, Delegate func) { - foreach (var method in handler.GetType().GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + var parameterInfos = func.Method.GetParameters(); + var args = new object[parameterInfos.Length]; + + // If the method has only one parameter of type JArray, invoke the method directly with the arguments + if (parameterInfos.Length == 1 && parameterInfos[0].ParameterType == typeof(JArray)) { - var attribute = method.GetCustomAttribute(); - var attributeWithParams = method.GetCustomAttribute(); - if (attribute is null && attributeWithParams is null) continue; - if (attribute is not null && attributeWithParams is not null) throw new InvalidOperationException("Method cannot have both RpcMethodAttribute and RpcMethodWithParamsAttribute"); + return func.DynamicInvoke(arguments); + } - if (attribute is not null) + for (var i = 0; i < parameterInfos.Length; i++) + { + var param = parameterInfos[i]; + if (arguments.Count > i && arguments[i] != null) { - var name = string.IsNullOrEmpty(attribute.Name) ? method.Name.ToLowerInvariant() : attribute.Name; - methods[name] = method.CreateDelegate>(handler); + try + { + args[i] = ParameterConverter.AsParameter(arguments[i], param.ParameterType); + } + catch (Exception e) when (e is not RpcException) + { + throw new ArgumentException($"Invalid value for parameter '{param.Name}'", e); + } } - - if (attributeWithParams is not null) + else { - var name = string.IsNullOrEmpty(attributeWithParams.Name) ? method.Name.ToLowerInvariant() : attributeWithParams.Name; + if (param.IsOptional) + { + args[i] = param.DefaultValue; + } + else if (param.ParameterType.IsValueType && Nullable.GetUnderlyingType(param.ParameterType) == null) + { + throw new ArgumentException($"Required parameter '{param.Name}' is missing"); + } + else + { + args[i] = null; + } + } + } - var parameters = method.GetParameters().Select(p => p.ParameterType).ToArray(); - var delegateType = Expression.GetDelegateType(parameters.Concat([method.ReturnType]).ToArray()); + return func.DynamicInvoke(args); + } - _methodsWithParams[name] = Delegate.CreateDelegate(delegateType, handler, method); - } + public void RegisterMethods(object handler) + { + var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + foreach (var method in handler.GetType().GetMethods(flags)) + { + var rpcMethod = method.GetCustomAttribute(); + if (rpcMethod is null) continue; + + var name = string.IsNullOrEmpty(rpcMethod.Name) ? method.Name.ToLowerInvariant() : rpcMethod.Name; + var parameters = method.GetParameters().Select(p => p.ParameterType).ToArray(); + var delegateType = Expression.GetDelegateType(parameters.Concat([method.ReturnType]).ToArray()); + + _methods[name] = Delegate.CreateDelegate(delegateType, handler, method); } } } diff --git a/tests/Neo.Plugins.RpcServer.Tests/UT_RpcServer.cs b/tests/Neo.Plugins.RpcServer.Tests/UT_RpcServer.cs index 97479adf4b..44eadb4bee 100644 --- a/tests/Neo.Plugins.RpcServer.Tests/UT_RpcServer.cs +++ b/tests/Neo.Plugins.RpcServer.Tests/UT_RpcServer.cs @@ -187,7 +187,7 @@ private async Task SimulatePostRequest(string requestBody) [TestMethod] public async Task TestProcessRequest_MalformedJsonPostBody() { - var malformedJson = "{\"jsonrpc\": \"2.0\", \"method\": \"getblockcount\", \"params\": [], \"id\": 1"; // Missing closing brace + var malformedJson = """{"jsonrpc": "2.0", "method": "getblockcount", "params": [], "id": 1"""; // Missing closing brace var response = await SimulatePostRequest(malformedJson); Assert.IsNotNull(response["error"]); @@ -207,12 +207,14 @@ public async Task TestProcessRequest_EmptyBatch() [TestMethod] public async Task TestProcessRequest_MixedBatch() { - var mixedBatchJson = "[" + - "{\"jsonrpc\": \"2.0\", \"method\": \"getblockcount\", \"params\": [], \"id\": 1}," + // Valid - "{\"jsonrpc\": \"2.0\", \"method\": \"nonexistentmethod\", \"params\": [], \"id\": 2}," + // Invalid method - "{\"jsonrpc\": \"2.0\", \"method\": \"getblock\", \"params\": [\"invalid_index\"], \"id\": 3}," + // Invalid params - "{\"jsonrpc\": \"2.0\", \"method\": \"getversion\", \"id\": 4}" + // Valid (no params needed) - "]"; + var mixedBatchJson = """ + [ + {"jsonrpc": "2.0", "method": "getblockcount", "params": [], "id": 1}, + {"jsonrpc": "2.0", "method": "nonexistentmethod", "params": [], "id": 2}, + {"jsonrpc": "2.0", "method": "getblock", "params": ["invalid_index"], "id": 3}, + {"jsonrpc": "2.0", "method": "getversion", "id": 4} + ] + """; var response = await SimulatePostRequest(mixedBatchJson); Assert.IsInstanceOfType(response, typeof(JArray)); @@ -240,5 +242,43 @@ public async Task TestProcessRequest_MixedBatch() Assert.IsNotNull(batchResults[3]["result"]); Assert.AreEqual(4, batchResults[3]["id"].AsNumber()); } + + private class MockRpcMethods + { + [RpcMethod] + internal JToken GetMockMethod() => "mock"; + } + + [TestMethod] + public async Task TestRegisterMethods() + { + _rpcServer.RegisterMethods(new MockRpcMethods()); + + // Request ProcessAsync with a valid request + var context = new DefaultHttpContext(); + var body = """ + {"jsonrpc": "2.0", "method": "getmockmethod", "params": [], "id": 1 } + """; + context.Request.Method = "POST"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); + context.Request.ContentType = "application/json"; + + // Set up a writable response body + var responseBody = new MemoryStream(); + context.Response.Body = responseBody; + + await _rpcServer.ProcessAsync(context); + Assert.IsNotNull(context.Response.Body); + + // Reset the stream position to read from the beginning + responseBody.Position = 0; + var output = new StreamReader(responseBody).ReadToEnd(); + + // Parse the JSON response and check the result + var responseJson = JToken.Parse(output); + Assert.IsNotNull(responseJson["result"]); + Assert.AreEqual("mock", responseJson["result"].AsString()); + Assert.AreEqual(200, context.Response.StatusCode); + } } }