Skip to content

Commit

Permalink
http outcall mocking
Browse files Browse the repository at this point in the history
  • Loading branch information
Gekctek committed Dec 23, 2024
1 parent ebe6a3d commit caa68bb
Show file tree
Hide file tree
Showing 10 changed files with 209 additions and 71 deletions.
48 changes: 24 additions & 24 deletions src/PocketIC/API.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 16 additions & 30 deletions src/PocketIC/IPocketIcHttpClient.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Net;
using EdjCase.ICP.Agent.Responses;
using EdjCase.ICP.Candid.Models;

Expand Down Expand Up @@ -111,13 +112,6 @@ Task<CandidArg> QueryCallAsync(
/// <returns>The current timestamp</returns>
Task<ICTimestamp> GetTimeAsync(int instanceId);

/// <summary>
/// Gets pending canister HTTP requests
/// </summary>
/// <param name="instanceId">The id of the PocketIC instance</param>
/// <returns>The pending canister HTTP request</returns>
Task<CanisterHttpRequest> GetCanisterHttpAsync(int instanceId);

/// <summary>
/// Gets the cycles balance of a canister
/// </summary>
Expand Down Expand Up @@ -249,20 +243,28 @@ Task<RequestId> SubmitIngressMessageAsync(
/// <param name="instanceId">The id of the IC instance</param>
Task TickAsync(int instanceId);


/// <summary>
/// Gets pending canister HTTP Outcall requests (not http calls to a canister)
/// </summary>
/// <param name="instanceId">The id of the PocketIC instance</param>
/// <returns>The pending canister HTTP request</returns>
Task<List<CanisterHttpRequest>> GetCanisterHttpAsync(int instanceId);

/// <summary>
/// Mocks a response to a canister HTTP request
/// Mocks a response to a canister HTTP Outcall request (not an http call to a canister)
/// </summary>
/// <param name="instanceId">The id of the IC instance</param>
/// <param name="requestId">The id of the HTTP request</param>
/// <param name="subnetId">The subnet id of the canister</param>
/// <param name="response">The response to send</param>
/// <param name="additionalResponses">Additional responses to send</param>
/// <param name="additionalResponses">Optional Additional responses to send</param>
Task MockCanisterHttpResponseAsync(
int instanceId,
ulong requestId,
Principal subnetId,
CanisterHttpResponse response,
List<CanisterHttpResponse> additionalResponses
List<CanisterHttpResponse>? additionalResponses = null
);

/// <summary>
Expand Down Expand Up @@ -683,7 +685,7 @@ public class CanisterHttpRequest
/// <summary>
/// The HTTP headers for the request
/// </summary>
public required List<CanisterHttpHeader> Headers { get; set; }
public required List<(string Key, string Value)> Headers { get; set; }

/// <summary>
/// The body of the request
Expand All @@ -696,22 +698,6 @@ public class CanisterHttpRequest
public required ulong? MaxResponseBytes { get; set; }
}

/// <summary>
/// HTTP header for canister requests/responses
/// </summary>
public class CanisterHttpHeader
{
/// <summary>
/// The header name
/// </summary>
public required string Name { get; set; }

/// <summary>
/// The header value
/// </summary>
public required string Value { get; set; }
}

/// <summary>
/// HTTP methods supported for canister HTTP calls
/// </summary>
Expand All @@ -734,7 +720,7 @@ public enum CanisterHttpMethod
/// <summary>
/// Base class for HTTP responses to canister HTTP requests
/// </summary>
public class CanisterHttpResponse { }
public abstract class CanisterHttpResponse { }

/// <summary>
/// Successful HTTP response to a canister HTTP request
Expand All @@ -744,12 +730,12 @@ public class CanisterHttpReply : CanisterHttpResponse
/// <summary>
/// The HTTP status code
/// </summary>
public required ushort Status { get; set; }
public required HttpStatusCode Status { get; set; }

/// <summary>
/// The response headers
/// </summary>
public required List<CanisterHttpHeader> Headers { get; set; }
public required List<(string Name, string Value)> Headers { get; set; }

/// <summary>
/// The response body
Expand Down
55 changes: 54 additions & 1 deletion src/PocketIC/PocketIc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,60 @@ public async Task<RequestId> UpdateCallRawAsynchronousAsync(
);
}


/// <summary>
/// Executes an update call on a canister with a raw CandidArg and raw CandidArg response with a single HTTP outcall mock response.
/// If there are multiple outcalls, only one will be mocked, and if there are none, an exception will be thrown.
/// NOTE: If you want more advanced outcall mocking, use the <see cref="IPocketIcHttpClient"/> directly
/// </summary>
/// <param name="sender">The principal making the call</param>
/// <param name="canisterId">The target canister ID</param>
/// <param name="method">The method name to call</param>
/// <param name="arg">The raw candid argument for the call</param>
/// <param name="response">The HTTP outcall mock response</param>
/// <param name="additionalResponses">Optional additional HTTP outcall mock responses</param>
/// <param name="effectivePrincipal">Optional effective principal for the call, defaults to canister id</param>
/// <returns>A raw candid argument from the response</returns>
public async Task<CandidArg> UpdateCallRawWithHttpOutcallMockAsync(
Principal sender,
Principal canisterId,
string method,
CandidArg arg,
CanisterHttpResponse response,
List<CanisterHttpResponse>? additionalResponses = null,
EffectivePrincipal? effectivePrincipal = null
)
{
effectivePrincipal ??= EffectivePrincipal.Canister(canisterId);
RequestId requestId = await this.HttpClient.SubmitIngressMessageAsync(
this.InstanceId,
sender,
canisterId,
method,
arg,
effectivePrincipal
);
await this.TickAsync(2);
List<CanisterHttpRequest> outcalls = await this.HttpClient.GetCanisterHttpAsync(this.InstanceId);
if (outcalls.Count < 1)
{
throw new Exception("No outcalls found");
}
CanisterHttpRequest outcall = outcalls[0];


await this.HttpClient.MockCanisterHttpResponseAsync(
this.InstanceId,
outcall.RequestId,
outcall.SubnetId,
response,
additionalResponses
);

return await this.HttpClient.AwaitIngressMessageAsync(this.InstanceId, requestId, effectivePrincipal);
}


/// <summary>
/// Awaits an update call response for a given request id, from the <see cref="UpdateCallRawAsynchronousAsync"/> method
/// </summary>
Expand Down Expand Up @@ -908,7 +962,6 @@ public static async Task<PocketIc> CreateAsync(
candidConverter
);
}

}

/// <summary>
Expand Down
43 changes: 27 additions & 16 deletions src/PocketIC/PocketIcHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,30 +245,38 @@ public async Task<ICTimestamp> GetTimeAsync(int instanceId)
}
return ICTimestamp.FromNanoSeconds(response!["nanos_since_epoch"].Deserialize<ulong>()!);
}

/// <inheritdoc />
public async Task<CanisterHttpRequest> GetCanisterHttpAsync(int instanceId)
public async Task<List<CanisterHttpRequest>> GetCanisterHttpAsync(int instanceId)
{
JsonNode? response = await this.GetJsonAsync($"/instances/{instanceId}/read/get_canister_http");

if (response == null)
{
throw new Exception("There was no json response from the server");
}
return response
.AsArray()
.Select(r => DeserializeCanisterHttpRequest(r!))
.ToList();
}

private static CanisterHttpRequest DeserializeCanisterHttpRequest(JsonNode node)
{
return new CanisterHttpRequest
{
Body = response!["body"].Deserialize<byte[]>()!,
Headers = response!["headers"]!.AsObject()!.Select(kv => new CanisterHttpHeader
{
Name = kv.Key,
Value = kv.Value.Deserialize<string>()!
}).ToList(),
Url = response!["url"].Deserialize<string>()!,
SubnetId = Principal.FromBytes(response!["subnet_id"].Deserialize<byte[]>()!),
HttpMethod = Enum.Parse<CanisterHttpMethod>(response!["http_method"].Deserialize<string>()!),
MaxResponseBytes = response!["max_response_bytes"].Deserialize<ulong?>()!,
RequestId = response!["request_id"].Deserialize<ulong>()!
Body = node["body"].Deserialize<byte[]>()!,
Headers = node["headers"]!.AsArray()
!.Select(h => (h!["name"].Deserialize<string>()!, h!["value"].Deserialize<string>()!))
.ToList(),
Url = node["url"].Deserialize<string>()!,
SubnetId = Principal.FromBytes(node["subnet_id"]!["subnet_id"].Deserialize<byte[]>()!),
HttpMethod = Enum.Parse<CanisterHttpMethod>(node["http_method"].Deserialize<string>()!, ignoreCase: true),
MaxResponseBytes = node["max_response_bytes"].Deserialize<ulong?>()!,
RequestId = node["request_id"].Deserialize<ulong>()!
};
}

/// <inheritdoc />
public async Task<ulong> GetCyclesBalanceAsync(int instanceId, Principal canisterId)
{
Expand Down Expand Up @@ -583,18 +591,21 @@ public async Task MockCanisterHttpResponseAsync(
ulong requestId,
Principal subnetId,
CanisterHttpResponse response,
List<CanisterHttpResponse> additionalResponses
List<CanisterHttpResponse>? additionalResponses = null
)
{
var request = new JsonObject
{
["request_id"] = JsonValue.Create(requestId),
["subnet_id"] = JsonValue.Create(subnetId.Raw),
["subnet_id"] = new JsonObject(new Dictionary<string, JsonNode?>
{
["subnet_id"] = Convert.ToBase64String(subnetId.Raw)
}),
["response"] = PocketIcHttpClient.SerializeCanisterHttpResponse(response),
["additional_responses"] = JsonValue.Create(
additionalResponses
.Select(r => PocketIcHttpClient.SerializeCanisterHttpResponse(r))
.ToArray()
?.Select(r => PocketIcHttpClient.SerializeCanisterHttpResponse(r))
.ToArray() ?? []
)
};
await this.PostJsonAsync($"/instances/{instanceId}/update/mock_canister_http", request);
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.dfx
.env
16 changes: 16 additions & 0 deletions test/PocketIC.Tests/CanisterWasmModules/src/http_outcall/dfx.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"canisters": {
"http_outcall": {
"main": "main.mo",
"type": "motoko"
}
},
"defaults": {
"build": {
"args": "",
"packtool": ""
}
},
"output_env_file": ".env",
"version": 1
}
27 changes: 27 additions & 0 deletions test/PocketIC.Tests/CanisterWasmModules/src/http_outcall/main.mo
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Blob "mo:base/Blob";
import Cycles "mo:base/ExperimentalCycles";
import IC "ic:aaaaa-aa";

actor {
public func http_outcall() : async Blob {
let url = "https://example.com/api";
let request_headers = [
{ name = "Test"; value = "test" },
];

let http_request : IC.http_request_args = {
url = url;
max_response_bytes = null; // optional
headers = request_headers;
body = null; // for GET request, use ?request_body for POST
method = #get; // or #post for POST request
transform = null;
};
Cycles.add<system>(230_850_258_000);

let http_response : IC.http_request_result = await IC.http_request(http_request);

http_response.body;
};

};
Loading

0 comments on commit caa68bb

Please sign in to comment.