From a19981e7c14986b830516411da0e8504c4249f32 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Thu, 4 May 2023 13:16:40 +0200 Subject: [PATCH 01/20] add ic-cdk-http-kit --- Cargo.toml | 1 + src/ic-cdk-http-kit/Cargo.toml | 29 ++ src/ic-cdk-http-kit/src/lib.rs | 91 +++++++ src/ic-cdk-http-kit/src/mock.rs | 167 ++++++++++++ src/ic-cdk-http-kit/src/request.rs | 216 +++++++++++++++ src/ic-cdk-http-kit/src/response.rs | 52 ++++ src/ic-cdk-http-kit/src/storage.rs | 52 ++++ src/ic-cdk-http-kit/tests/api.rs | 393 ++++++++++++++++++++++++++++ 8 files changed, 1001 insertions(+) create mode 100644 src/ic-cdk-http-kit/Cargo.toml create mode 100644 src/ic-cdk-http-kit/src/lib.rs create mode 100644 src/ic-cdk-http-kit/src/mock.rs create mode 100644 src/ic-cdk-http-kit/src/request.rs create mode 100644 src/ic-cdk-http-kit/src/response.rs create mode 100644 src/ic-cdk-http-kit/src/storage.rs create mode 100644 src/ic-cdk-http-kit/tests/api.rs diff --git a/Cargo.toml b/Cargo.toml index aa3892b7a..30979cd7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "src/ic-cdk", "src/ic-cdk-macros", "src/ic-cdk-timers", + "src/ic-cdk-http-kit", "library/ic-certified-map", "library/ic-ledger-types", "e2e-tests", diff --git a/src/ic-cdk-http-kit/Cargo.toml b/src/ic-cdk-http-kit/Cargo.toml new file mode 100644 index 000000000..11a3c8552 --- /dev/null +++ b/src/ic-cdk-http-kit/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "ic-cdk-http-kit" +description = "A simple toolkit for constructing and testing HTTP Outcalls on the Internet Computer" +version = "0.1.0" +edition = "2021" +authors = ["DFINITY Team", "The Internet Computer Project Developers", "Maksym Arutyunyan"] +repository = "https://github.com/dfinity/cdk-rs/ic-cdk-http-kit" +homepage = "https://github.com/dfinity/cdk-rs/ic-cdk-http-kit" +documentation = "https://docs.rs/ic-cdk-http-kit" +readme = "README.md" +license = "Apache-2.0" +keywords = ["http", "http-outcalls", "mock", "canister", "internet-computer"] +categories = ["development-tools::testing", "web-programming", "internet-computer"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +candid = "0.8.2" +ic-cdk = "0.6.0" +ic-cdk-macros = "0.6.0" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { version = "1.15.0", features = [ "full" ] } + +[dev-dependencies] +tokio = { version = "1.15.0", features = [ "full" ] } +futures = "0.3.27" +serde_json = "1.0.94" +tokio-test = "0.4.2" diff --git a/src/ic-cdk-http-kit/src/lib.rs b/src/ic-cdk-http-kit/src/lib.rs new file mode 100644 index 000000000..e153d87e2 --- /dev/null +++ b/src/ic-cdk-http-kit/src/lib.rs @@ -0,0 +1,91 @@ +//! A simple toolkit for constructing and testing HTTP Outcalls on the Internet Computer. +//! +//! It streamlines unit testing of HTTP Outcalls and provides user-friendly utilities. +//! The crate simulates the `http_request` function from `ic_cdk` by retrieving mock responses, checking the maximum allowed size, and applying a transformation function if specified, optionally with a delay to simulate latency. +//! +//! Note: To properly simulate the transformation function inside `ic_http_outcall_kit::http_request`, the request builder must be used. +//! +//! ## Features +//! +//! - Simple interface for creating HTTP requests and responses +//! - Support for HTTP response transformation functions +//! - Control over response size with a maximum byte limit +//! - Mock response with optional delay to simulate latency +//! - Assert the number of times a request was called +//! +//! ## Examples +//! +//! ### Creating a Request +//! +//! ```ignore +//! fn transform_function(arg: TransformArgs) -> HttpResponse { +//! // Modify arg.response here +//! arg.response +//! } +//! +//! let request = ic_http_outcall_kit::create_request() +//! .get("https://dummyjson.com/todos/1") +//! .max_response_bytes(1_024) +//! .transform(transform_function, vec![]) +//! .build(); +//! ``` +//! +//! ### Creating a Response +//! +//! ```ignore +//! let mock_response = ic_http_outcall_kit::create_response() +//! .status(200) +//! .body("some text") +//! .build(); +//! ``` +//! +//! ### Mocking +//! +//! ```ignore +//! ic_http_outcall_kit::mock(request, Ok(mock_response)); +//! ic_http_outcall_kit::mock_with_delay(request, Ok(mock_response), Duration::from_sec(2)); +//! +//! let mock_error = (RejectionCode::SysFatal, "system fatal error".to_string()); +//! ic_http_outcall_kit::mock(request, Err(mock_error)); +//! ic_http_outcall_kit::mock_with_delay(request, Err(mock_error), Duration::from_sec(2)); +//! ``` +//! +//! ### Making an HTTP Outcall +//! +//! ```ignore +//! let (response,) = ic_http_outcall_kit::http_request(request).await.unwrap(); +//! ``` +//! +//! ### Asserts +//! +//! ```ignore +//! assert_eq!(response.status, 200); +//! assert_eq!(response.body, "transformed body".to_owned().into_bytes()); +//! assert_eq!(ic_http_outcall_kit::times_called(request), 1); +//! ``` +//! +//! ### More Examples +//! +//! Please refer to the provided usage examples in the [tests](./tests) or [examples](./examples) directories. +//! +//! ## Contributing +//! +//! Please follow the guidelines in the [CONTRIBUTING.md](.github/CONTRIBUTING.md) document. +//! +//! ## References +//! +//! - [Integrations](https://internetcomputer.org/docs/current/developer-docs/integrations/) +//! - [HTTPS Outcalls](https://internetcomputer.org/docs/current/developer-docs/integrations/http_requests/) +//! - HTTP Outcalls, [IC method http_request](https://internetcomputer.org/docs/current/references/ic-interface-spec#ic-http_request) +//! - Serving HTTP responses, [The HTTP Gateway protocol](https://internetcomputer.org/docs/current/references/ic-interface-spec#http-gateway) +//! - [Transformation Function](https://internetcomputer.org/docs/current/developer-docs/integrations/http_requests/http_requests-how-it-works#transformation-function) +//! + +mod mock; +mod request; +mod response; +mod storage; + +pub use mock::*; +pub use request::*; +pub use response::*; diff --git a/src/ic-cdk-http-kit/src/mock.rs b/src/ic-cdk-http-kit/src/mock.rs new file mode 100644 index 000000000..29cab7c0f --- /dev/null +++ b/src/ic-cdk-http-kit/src/mock.rs @@ -0,0 +1,167 @@ +//! Mocks HTTP requests. + +use crate::storage; +use ic_cdk::api::call::{CallResult, RejectionCode}; +use ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpResponse, TransformArgs, +}; +use std::time::Duration; + +type MockError = (RejectionCode, String); + +#[derive(Clone)] +pub(crate) struct Mock { + pub(crate) request: CanisterHttpRequestArgument, + result: Option>, + delay: Duration, + times_called: u64, +} + +impl Mock { + /// Creates a new mock. + pub fn new( + request: CanisterHttpRequestArgument, + result: Result, + delay: Duration, + ) -> Self { + Self { + request, + result: Some(result), + delay, + times_called: 0, + } + } +} + +/// Mocks a HTTP request. +pub fn mock(request: CanisterHttpRequestArgument, result: Result) { + mock_with_delay(request, result, Duration::from_secs(0)); +} + +/// Mocks a HTTP request with a delay. +pub fn mock_with_delay( + request: CanisterHttpRequestArgument, + result: Result, + delay: Duration, +) { + storage::mock_insert(Mock::new(request, result, delay)); +} + +/// Returns the number of times a HTTP request was called. +/// Returns 0 if no mock has been found for the request. +pub fn times_called(request: CanisterHttpRequestArgument) -> u64 { + storage::mock_get(&request) + .map(|mock| mock.times_called) + .unwrap_or(0) +} + +/// Returns a sorted list of registered transform function names. +pub fn registered_transform_function_names() -> Vec { + storage::transform_function_names() +} + +/// Make an HTTP request to a given URL and return the HTTP response, possibly after a transformation. +/// +/// This is a helper function that compiles differently depending on the target architecture. +/// For wasm32 (assuming a canister in prod), it calls the IC method `http_request`. +/// For other architectures, it calls a mock function. +pub async fn http_request(arg: CanisterHttpRequestArgument) -> CallResult<(HttpResponse,)> { + #[cfg(target_arch = "wasm32")] + { + ic_cdk::api::management_canister::http_request::http_request(arg).await + } + + #[cfg(not(target_arch = "wasm32"))] + { + mock_http_request(arg).await + } +} + +/// Handles incoming HTTP requests by retrieving a mock response based +/// on the request, possibly delaying the response, transforming the response if necessary, +/// and returning it. If there is no mock found, it returns an error. +async fn mock_http_request( + request: CanisterHttpRequestArgument, +) -> Result<(HttpResponse,), (RejectionCode, String)> { + let mut mock = storage::mock_get(&request) + .ok_or((RejectionCode::CanisterReject, "No mock found".to_string()))?; + mock.times_called += 1; + storage::mock_insert(mock.clone()); + + // Delay the response if necessary. + if mock.delay > Duration::from_secs(0) { + // Use a non-blocking sleep for tests, while wasm32 does not support tokio. + #[cfg(not(target_arch = "wasm32"))] + tokio::time::sleep(mock.delay).await; + } + + let mock_response = match mock.result { + None => panic!("Mock response is missing"), + // Return the error if one is specified. + Some(Err(error)) => return Err(error), + Some(Ok(response)) => response, + }; + + // Check if the response body exceeds the maximum allowed size. + if let Some(max_response_bytes) = mock.request.max_response_bytes { + if mock_response.body.len() as u64 > max_response_bytes { + return Err(( + RejectionCode::SysFatal, + format!( + "Value of 'Content-length' header exceeds http body size limit, {} > {}.", + mock_response.body.len(), + max_response_bytes + ), + )); + } + } + + // Apply the transform function if one is specified. + let transformed_response = call_transform_function( + mock.request, + TransformArgs { + response: mock_response.clone(), + context: vec![], + }, + ) + .unwrap_or(mock_response); + + Ok((transformed_response,)) +} + +/// Calls the transform function if one is specified in the request. +fn call_transform_function( + request: CanisterHttpRequestArgument, + arg: TransformArgs, +) -> Option { + request + .transform + .and_then(|t| storage::transform_function_call(t.function.0.method, arg)) +} + +/// Create a hash from a `CanisterHttpRequestArgument`, which includes its URL, +/// method, headers, body, and optionally, its transform function name. +/// This is because `CanisterHttpRequestArgument` does not have `Hash` implemented. +pub(crate) fn hash(request: &CanisterHttpRequestArgument) -> String { + let mut hash = String::new(); + + hash.push_str(&request.url); + hash.push_str(&format!("{:?}", request.max_response_bytes)); + hash.push_str(&format!("{:?}", request.method)); + for header in request.headers.iter() { + hash.push_str(&header.name); + hash.push_str(&header.value); + } + let body = String::from_utf8(request.body.as_ref().unwrap_or(&vec![]).clone()) + .expect("Raw response is not UTF-8 encoded."); + hash.push_str(&body); + let function_name = request + .transform + .as_ref() + .map(|transform| transform.function.0.method.clone()); + if let Some(name) = function_name { + hash.push_str(&name); + } + + hash +} diff --git a/src/ic-cdk-http-kit/src/request.rs b/src/ic-cdk-http-kit/src/request.rs new file mode 100644 index 000000000..93d61187f --- /dev/null +++ b/src/ic-cdk-http-kit/src/request.rs @@ -0,0 +1,216 @@ +//! Helper functions and builders for creating HTTP requests and responses. + +use candid::Principal; +use ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpHeader, HttpMethod, HttpResponse, TransformArgs, + TransformContext, TransformFunc, +}; + +/// Creates a new HTTP request builder. +pub fn create_request() -> CanisterHttpRequestArgumentBuilder { + CanisterHttpRequestArgumentBuilder::new() +} + +/// A builder for a HTTP request. +#[derive(Debug)] +pub struct CanisterHttpRequestArgumentBuilder(CanisterHttpRequestArgument); + +impl CanisterHttpRequestArgumentBuilder { + /// Creates a new HTTP request builder. + pub fn new() -> Self { + Self(CanisterHttpRequestArgument { + url: String::new(), + max_response_bytes: None, + method: HttpMethod::GET, + headers: Vec::new(), + body: None, + transform: None, + }) + } + + /// Sets the URL of the HTTP request. + pub fn url(mut self, url: &str) -> Self { + self.0.url = url.to_string(); + self + } + + /// Sets the HTTP method to GET and the URL of the HTTP request. + pub fn get(mut self, url: &str) -> Self { + self.0.method = HttpMethod::GET; + self.0.url = url.to_string(); + self + } + + /// Sets the HTTP method to POST and the URL of the HTTP request. + pub fn post(mut self, url: &str) -> Self { + self.0.method = HttpMethod::POST; + self.0.url = url.to_string(); + self + } + + /// Sets the HTTP method to HEAD and the URL of the HTTP request. + pub fn head(mut self, url: &str) -> Self { + self.0.method = HttpMethod::HEAD; + self.0.url = url.to_string(); + self + } + + /// Sets the maximum response size in bytes. + pub fn max_response_bytes(mut self, max_response_bytes: u64) -> Self { + self.0.max_response_bytes = Some(max_response_bytes); + self + } + + /// Sets the HTTP method of the HTTP request. + pub fn method(mut self, method: HttpMethod) -> Self { + self.0.method = method; + self + } + + /// Adds a HTTP header to the HTTP request. + pub fn header(mut self, name: String, value: String) -> Self { + self.0.headers.push(HttpHeader { name, value }); + self + } + + /// Sets the HTTP request body. + pub fn body(mut self, body: Vec) -> Self { + self.0.body = Some(body); + self + } + + /// Sets the transform function. + pub fn transform(mut self, func: T, context: Vec) -> Self + where + T: Fn(TransformArgs) -> HttpResponse + 'static, + { + self.0.transform = Some(create_transform_context(func, context)); + self + } + + /// Builds the HTTP request. + pub fn build(self) -> CanisterHttpRequestArgument { + self.0 + } +} + +impl Default for CanisterHttpRequestArgumentBuilder { + fn default() -> Self { + Self::new() + } +} + +fn create_transform_context(func: T, context: Vec) -> TransformContext +where + T: Fn(TransformArgs) -> HttpResponse + 'static, +{ + #[cfg(target_arch = "wasm32")] + { + TransformContext::new(func, context) + } + + #[cfg(not(target_arch = "wasm32"))] + { + // crate::id() can not be called outside of canister, that's why for testing + // it is replaced with Principal::management_canister(). + let principal = Principal::management_canister(); + let method = get_function_name(&func).to_string(); + super::storage::transform_function_insert(method.clone(), Box::new(func)); + + TransformContext { + function: TransformFunc(candid::Func { principal, method }), + context, + } + } +} + +fn get_function_name(_: &F) -> &'static str { + let full_name = std::any::type_name::(); + match full_name.rfind(':') { + Some(index) => &full_name[index + 1..], + None => full_name, + } +} + +#[cfg(test)] +mod test { + use super::*; + use ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpResponse, TransformArgs, + }; + + /// A test transform function. + fn transform_function_1(arg: TransformArgs) -> HttpResponse { + arg.response + } + + /// A test transform function. + fn transform_function_2(arg: TransformArgs) -> HttpResponse { + arg.response + } + + /// Inserts the provided transform function into a thread-local hashmap. + fn insert(f: T) + where + T: Fn(TransformArgs) -> HttpResponse + 'static, + { + let name = get_function_name(&f).to_string(); + crate::storage::transform_function_insert(name, Box::new(f)); + } + + /// This test makes sure that transform function names are preserved + /// when passing to the function. + #[test] + fn test_transform_function_names() { + // Arrange. + insert(transform_function_1); + insert(transform_function_2); + + // Act. + let names = crate::mock::registered_transform_function_names(); + + // Assert. + assert_eq!(names, vec!["transform_function_1", "transform_function_2"]); + } + + /// Transform function which intentionally creates a new request passing + /// itself as the target transform function. + fn transform_function_with_overwrite(arg: TransformArgs) -> HttpResponse { + create_request_with_transform(); + arg.response + } + + /// Creates a request with a transform function which overwrites itself. + fn create_request_with_transform() -> CanisterHttpRequestArgument { + crate::create_request() + .url("https://www.example.com") + .transform(transform_function_with_overwrite, vec![]) + .build() + } + + // IMPORTANT: If this test hangs check the implementation of inserting + // transform function to the thread-local storage. + // + // This test simulates the case when transform function tries to + // rewrite itself in a thread-local storage while it is being executed. + // This may lead to a hang if the insertion to the thread-local storage + // is not written properly. + #[tokio::test] + async fn test_transform_function_call_without_a_hang() { + // Arrange + let request = create_request_with_transform(); + let mock_response = crate::create_response().build(); + crate::mock::mock(request.clone(), Ok(mock_response)); + + // Act + let (response,) = crate::mock::http_request(request.clone()).await.unwrap(); + + // Assert + assert_eq!(response.status, 200); + assert_eq!(crate::mock::times_called(request), 1); + assert_eq!( + crate::mock::registered_transform_function_names(), + vec!["transform_function_with_overwrite"] + ); + } +} diff --git a/src/ic-cdk-http-kit/src/response.rs b/src/ic-cdk-http-kit/src/response.rs new file mode 100644 index 000000000..3663323c9 --- /dev/null +++ b/src/ic-cdk-http-kit/src/response.rs @@ -0,0 +1,52 @@ +use ic_cdk::api::management_canister::http_request::{HttpHeader, HttpResponse}; + +const STATUS_CODE_OK: u64 = 200; + +/// Creates a new HTTP response builder. +pub fn create_response() -> HttpResponseBuilder { + HttpResponseBuilder::new() +} + +/// A builder for a HTTP response. +#[derive(Debug)] +pub struct HttpResponseBuilder(HttpResponse); + +impl HttpResponseBuilder { + /// Creates a new HTTP response builder. + pub fn new() -> Self { + Self(HttpResponse { + status: candid::Nat::from(STATUS_CODE_OK), + headers: Vec::new(), + body: Vec::new(), + }) + } + + /// Sets the HTTP status code. + pub fn status(mut self, status: u64) -> Self { + self.0.status = candid::Nat::from(status); + self + } + + /// Adds a HTTP header to the HTTP response. + pub fn header(mut self, header: HttpHeader) -> Self { + self.0.headers.push(header); + self + } + + /// Sets the HTTP response body. + pub fn body(mut self, body: &str) -> Self { + self.0.body = body.as_bytes().to_vec(); + self + } + + /// Builds the HTTP response. + pub fn build(self) -> HttpResponse { + self.0 + } +} + +impl Default for HttpResponseBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/src/ic-cdk-http-kit/src/storage.rs b/src/ic-cdk-http-kit/src/storage.rs new file mode 100644 index 000000000..9c23f6b7f --- /dev/null +++ b/src/ic-cdk-http-kit/src/storage.rs @@ -0,0 +1,52 @@ +use crate::mock::{hash, Mock}; +use ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpResponse, TransformArgs, +}; +use std::collections::HashMap; +use std::sync::RwLock; + +thread_local! { + static MOCKS: RwLock> = RwLock::new(HashMap::new()); + + static TRANSFORM_FUNCTIONS: RwLock>> = RwLock::new(HashMap::new()); +} + +/// Inserts the provided mock into a thread-local hashmap. +pub(crate) fn mock_insert(mock: Mock) { + MOCKS.with(|cell| { + cell.write().unwrap().insert(hash(&mock.request), mock); + }); +} + +/// Returns a cloned mock from the thread-local hashmap that corresponds to the provided request. +pub(crate) fn mock_get(request: &CanisterHttpRequestArgument) -> Option { + MOCKS.with(|cell| cell.read().unwrap().get(&hash(request)).cloned()) +} + +type TransformFn = dyn Fn(TransformArgs) -> HttpResponse + 'static; + +/// Inserts the provided transform function into a thread-local hashmap. +/// If a transform function with the same name already exists, it is not inserted. +pub(crate) fn transform_function_insert(name: String, func: Box) { + TRANSFORM_FUNCTIONS.with(|cell| { + // This is a workaround to prevent the transform function from being + // overridden while it is being executed. + if cell.read().unwrap().get(&name).is_none() { + cell.write().unwrap().insert(name, func); + } + }); +} + +/// Executes the transform function that corresponds to the provided name. +pub(crate) fn transform_function_call(name: String, arg: TransformArgs) -> Option { + TRANSFORM_FUNCTIONS.with(|cell| cell.read().unwrap().get(&name).map(|f| f(arg))) +} + +/// Returns a sorted list of transform function names. +pub(crate) fn transform_function_names() -> Vec { + TRANSFORM_FUNCTIONS.with(|cell| { + let mut names: Vec = cell.read().unwrap().keys().cloned().collect(); + names.sort(); + names + }) +} diff --git a/src/ic-cdk-http-kit/tests/api.rs b/src/ic-cdk-http-kit/tests/api.rs new file mode 100644 index 000000000..49f1ffd5d --- /dev/null +++ b/src/ic-cdk-http-kit/tests/api.rs @@ -0,0 +1,393 @@ +use ic_cdk::api::call::RejectionCode; +use ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpResponse, TransformArgs, +}; +use std::time::{Duration, Instant}; + +const STATUS_CODE_OK: u64 = 200; +const STATUS_CODE_NOT_FOUND: u64 = 404; + +#[tokio::test] +async fn test_http_request_no_transform() { + // Arrange + let body = "some text"; + let request = ic_cdk_http_kit::create_request() + .get("https://example.com") + .build(); + let mock_response = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_OK) + .body(body) + .build(); + ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); + + // Act + let (response,) = ic_cdk_http_kit::http_request(request.clone()) + .await + .unwrap(); + + // Assert + assert_eq!(response.status, STATUS_CODE_OK); + assert_eq!(response.body, body.to_owned().into_bytes()); + assert_eq!(ic_cdk_http_kit::times_called(request), 1); +} + +#[tokio::test] +async fn test_http_request_called_several_times() { + // Arrange + let calls = 3; + let body = "some text"; + let request = ic_cdk_http_kit::create_request() + .get("https://example.com") + .build(); + let mock_response = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_OK) + .body(body) + .build(); + ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); + + // Act + for _ in 0..calls { + let (response,) = ic_cdk_http_kit::http_request(request.clone()) + .await + .unwrap(); + assert_eq!(response.status, STATUS_CODE_OK); + assert_eq!(response.body, body.to_owned().into_bytes()); + } + + // Assert + assert_eq!(ic_cdk_http_kit::times_called(request), calls); +} + +#[tokio::test] +async fn test_http_request_transform_status() { + // Arrange + fn transform(_arg: TransformArgs) -> HttpResponse { + ic_cdk_http_kit::create_response() + .status(STATUS_CODE_NOT_FOUND) + .build() + } + let request = ic_cdk_http_kit::create_request() + .get("https://example.com") + .transform(transform, vec![]) + .build(); + let mock_response = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_OK) + .body("some text") + .build(); + ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); + + // Act + let (response,) = ic_cdk_http_kit::http_request(request.clone()) + .await + .unwrap(); + + // Assert + assert_eq!(response.status, STATUS_CODE_NOT_FOUND); + assert_eq!(ic_cdk_http_kit::times_called(request), 1); +} + +#[tokio::test] +async fn test_http_request_transform_body() { + // Arrange + const ORIGINAL_BODY: &str = "original body"; + const TRANSFORMED_BODY: &str = "transformed body"; + fn transform(_arg: TransformArgs) -> HttpResponse { + ic_cdk_http_kit::create_response() + .body(TRANSFORMED_BODY) + .build() + } + let request = ic_cdk_http_kit::create_request() + .get("https://dummyjson.com/todos/1") + .transform(transform, vec![]) + .build(); + let mock_response = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_OK) + .body(ORIGINAL_BODY) + .build(); + ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); + + // Act + let (response,) = ic_cdk_http_kit::http_request(request.clone()) + .await + .unwrap(); + + // Assert + assert_eq!(response.body, TRANSFORMED_BODY.as_bytes().to_vec()); + assert_eq!(ic_cdk_http_kit::times_called(request), 1); +} + +#[tokio::test] +async fn test_http_request_transform_both_status_and_body() { + // Arrange + const ORIGINAL_BODY: &str = "original body"; + const TRANSFORMED_BODY: &str = "transformed body"; + + fn transform_status(arg: TransformArgs) -> HttpResponse { + let mut response = arg.response; + response.status = candid::Nat::from(STATUS_CODE_NOT_FOUND); + response + } + + fn transform_body(arg: TransformArgs) -> HttpResponse { + let mut response = arg.response; + response.body = TRANSFORMED_BODY.as_bytes().to_vec(); + response + } + + let request_1 = ic_cdk_http_kit::create_request() + .get("https://dummyjson.com/todos/1") + .transform(transform_status, vec![]) + .build(); + let mock_response_1 = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_NOT_FOUND) + .body(ORIGINAL_BODY) + .build(); + ic_cdk_http_kit::mock(request_1.clone(), Ok(mock_response_1)); + + let request_2 = ic_cdk_http_kit::create_request() + .get("https://dummyjson.com/todos/2") + .transform(transform_body, vec![]) + .build(); + let mock_response_2 = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_OK) + .body(TRANSFORMED_BODY) + .build(); + ic_cdk_http_kit::mock(request_2.clone(), Ok(mock_response_2)); + + // Act + let futures = vec![ + ic_cdk_http_kit::http_request(request_1.clone()), + ic_cdk_http_kit::http_request(request_2.clone()), + ]; + let results = futures::future::join_all(futures).await; + let responses: Vec<_> = results + .into_iter() + .filter(|result| result.is_ok()) + .map(|result| result.unwrap().0) + .collect(); + + // Assert + assert_eq!( + ic_cdk_http_kit::registered_transform_function_names(), + vec!["transform_body", "transform_status"] + ); + assert_eq!(responses.len(), 2); + assert_eq!(responses[0].status, STATUS_CODE_NOT_FOUND); + assert_eq!(responses[0].body, ORIGINAL_BODY.as_bytes().to_vec()); + assert_eq!(responses[1].status, STATUS_CODE_OK); + assert_eq!(responses[1].body, TRANSFORMED_BODY.as_bytes().to_vec()); + assert_eq!(ic_cdk_http_kit::times_called(request_1), 1); + assert_eq!(ic_cdk_http_kit::times_called(request_2), 1); +} + +#[tokio::test] +async fn test_http_request_max_response_bytes_ok() { + // Arrange + let max_response_bytes = 3; + let body_small_enough = "123"; + let request = ic_cdk_http_kit::create_request() + .get("https://example.com") + .max_response_bytes(max_response_bytes) + .build(); + let mock_response = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_OK) + .body(body_small_enough) + .build(); + ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); + + // Act + let result = ic_cdk_http_kit::http_request(request.clone()).await; + + // Assert + assert!(result.is_ok()); + assert_eq!(ic_cdk_http_kit::times_called(request), 1); +} + +#[tokio::test] +async fn test_http_request_max_response_bytes_error() { + // Arrange + let max_response_bytes = 3; + let body_too_big = "1234"; + let request = ic_cdk_http_kit::create_request() + .get("https://example.com") + .max_response_bytes(max_response_bytes) + .build(); + let mock_response = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_OK) + .body(body_too_big) + .build(); + ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); + + // Act + let result = ic_cdk_http_kit::http_request(request.clone()).await; + + // Assert + assert!(result.is_err()); + assert_eq!(ic_cdk_http_kit::times_called(request), 1); +} + +#[tokio::test] +async fn test_http_request_sequentially() { + // Arrange + let request_a = ic_cdk_http_kit::create_request().get("a").build(); + let request_b = ic_cdk_http_kit::create_request().get("b").build(); + let request_c = ic_cdk_http_kit::create_request().get("c").build(); + let mock_response = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_OK) + .build(); + ic_cdk_http_kit::mock_with_delay( + request_a.clone(), + Ok(mock_response.clone()), + Duration::from_millis(100), + ); + ic_cdk_http_kit::mock_with_delay( + request_b.clone(), + Ok(mock_response.clone()), + Duration::from_millis(200), + ); + ic_cdk_http_kit::mock_with_delay( + request_c.clone(), + Ok(mock_response), + Duration::from_millis(300), + ); + + // Act + let start = Instant::now(); + let _ = ic_cdk_http_kit::http_request(request_a.clone()).await; + let _ = ic_cdk_http_kit::http_request(request_b.clone()).await; + let _ = ic_cdk_http_kit::http_request(request_c.clone()).await; + println!("All finished after {} s", start.elapsed().as_secs_f32()); + + // Assert + assert!(start.elapsed() > Duration::from_millis(500)); + assert_eq!(ic_cdk_http_kit::times_called(request_a), 1); + assert_eq!(ic_cdk_http_kit::times_called(request_b), 1); + assert_eq!(ic_cdk_http_kit::times_called(request_c), 1); +} + +#[tokio::test] +async fn test_http_request_concurrently() { + // Arrange + let request_a = ic_cdk_http_kit::create_request().get("a").build(); + let request_b = ic_cdk_http_kit::create_request().get("b").build(); + let request_c = ic_cdk_http_kit::create_request().get("c").build(); + let mock_response = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_OK) + .build(); + ic_cdk_http_kit::mock_with_delay( + request_a.clone(), + Ok(mock_response.clone()), + Duration::from_millis(100), + ); + ic_cdk_http_kit::mock_with_delay( + request_b.clone(), + Ok(mock_response.clone()), + Duration::from_millis(200), + ); + ic_cdk_http_kit::mock_with_delay( + request_c.clone(), + Ok(mock_response), + Duration::from_millis(300), + ); + + // Act + let start = Instant::now(); + let futures = vec![ + ic_cdk_http_kit::http_request(request_a.clone()), + ic_cdk_http_kit::http_request(request_b.clone()), + ic_cdk_http_kit::http_request(request_c.clone()), + ]; + futures::future::join_all(futures).await; + println!("All finished after {} s", start.elapsed().as_secs_f32()); + + // Assert + assert!(start.elapsed() < Duration::from_millis(500)); + assert_eq!(ic_cdk_http_kit::times_called(request_a), 1); + assert_eq!(ic_cdk_http_kit::times_called(request_b), 1); + assert_eq!(ic_cdk_http_kit::times_called(request_c), 1); +} + +#[tokio::test] +async fn test_http_request_error() { + // Arrange + let request = ic_cdk_http_kit::create_request() + .get("https://example.com") + .build(); + let mock_error = (RejectionCode::SysFatal, "system fatal error".to_string()); + ic_cdk_http_kit::mock(request.clone(), Err(mock_error)); + + // Act + let result = ic_cdk_http_kit::http_request(request.clone()).await; + + // Assert + assert_eq!( + result, + Err((RejectionCode::SysFatal, "system fatal error".to_string())) + ); + assert_eq!(ic_cdk_http_kit::times_called(request), 1); +} + +#[tokio::test] +async fn test_http_request_error_with_delay() { + // Arrange + let request = ic_cdk_http_kit::create_request() + .get("https://example.com") + .build(); + let mock_error = (RejectionCode::SysFatal, "system fatal error".to_string()); + ic_cdk_http_kit::mock_with_delay(request.clone(), Err(mock_error), Duration::from_millis(200)); + + // Act + let start = Instant::now(); + let result = ic_cdk_http_kit::http_request(request.clone()).await; + + // Assert + assert!(start.elapsed() > Duration::from_millis(100)); + assert_eq!( + result, + Err((RejectionCode::SysFatal, "system fatal error".to_string())) + ); + assert_eq!(ic_cdk_http_kit::times_called(request), 1); +} + +/// Transform function which intentionally creates a new request passing +/// itself as the target transform function. +fn transform_function_with_overwrite(arg: TransformArgs) -> HttpResponse { + create_request_with_transform(); + arg.response +} + +/// Creates a request with a transform function which overwrites itself. +fn create_request_with_transform() -> CanisterHttpRequestArgument { + ic_cdk_http_kit::create_request() + .url("https://www.example.com") + .transform(transform_function_with_overwrite, vec![]) + .build() +} + +// IMPORTANT: If this test hangs check the implementation of inserting +// transform function to the thread-local storage. +// +// This test simulates the case when transform function tries to +// rewrite itself in a thread-local storage while it is being executed. +// This may lead to a hang if the insertion to the thread-local storage +// is not written properly. +#[tokio::test] +async fn test_transform_function_call_without_a_hang() { + // Arrange + let request = create_request_with_transform(); + let mock_response = ic_cdk_http_kit::create_response().build(); + ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); + + // Act + let (response,) = ic_cdk_http_kit::http_request(request.clone()) + .await + .unwrap(); + + // Assert + assert_eq!(response.status, 200); + assert_eq!(ic_cdk_http_kit::times_called(request), 1); + assert_eq!( + ic_cdk_http_kit::registered_transform_function_names(), + vec!["transform_function_with_overwrite"] + ); +} From e9d48db095e74762e97c9590691898ee87c5ed94 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Thu, 4 May 2023 13:20:50 +0200 Subject: [PATCH 02/20] add examples/ --- .gitignore | 1 + src/ic-cdk-http-kit/examples/Cargo.toml | 5 + .../examples/fetch_json/Cargo.toml | 22 +++ .../examples/fetch_json/candid.did | 3 + .../examples/fetch_json/dfx.json | 18 +++ .../fetch_json/e2e-tests/fetch_quote.sh | 24 ++++ .../examples/fetch_json/src/main.rs | 129 ++++++++++++++++++ 7 files changed, 202 insertions(+) create mode 100644 src/ic-cdk-http-kit/examples/Cargo.toml create mode 100644 src/ic-cdk-http-kit/examples/fetch_json/Cargo.toml create mode 100644 src/ic-cdk-http-kit/examples/fetch_json/candid.did create mode 100644 src/ic-cdk-http-kit/examples/fetch_json/dfx.json create mode 100755 src/ic-cdk-http-kit/examples/fetch_json/e2e-tests/fetch_quote.sh create mode 100644 src/ic-cdk-http-kit/examples/fetch_json/src/main.rs diff --git a/.gitignore b/.gitignore index bc533ffc9..f4ef8a21c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ target/ # DFX .dfx/ +.env # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html diff --git a/src/ic-cdk-http-kit/examples/Cargo.toml b/src/ic-cdk-http-kit/examples/Cargo.toml new file mode 100644 index 000000000..e05b08fa5 --- /dev/null +++ b/src/ic-cdk-http-kit/examples/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] + +members = [ + "fetch_json", +] diff --git a/src/ic-cdk-http-kit/examples/fetch_json/Cargo.toml b/src/ic-cdk-http-kit/examples/fetch_json/Cargo.toml new file mode 100644 index 000000000..d3a5000c7 --- /dev/null +++ b/src/ic-cdk-http-kit/examples/fetch_json/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "fetch_json" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[[bin]] +name = "fetch_json" +path = "src/main.rs" + +[dependencies] +candid = "0.8.2" +ic-cdk = "0.6.0" +ic-cdk-macros = "0.6.0" +ic-cdk-http-kit = { path = "../../" } +serde = { version = "1.0.158", features = [ "derive" ] } +serde_json = "1.0.94" + +[dev-dependencies] +tokio = { version = "1.15.0", features = [ "full" ] } +futures = "0.3.27" diff --git a/src/ic-cdk-http-kit/examples/fetch_json/candid.did b/src/ic-cdk-http-kit/examples/fetch_json/candid.did new file mode 100644 index 000000000..5e1994e50 --- /dev/null +++ b/src/ic-cdk-http-kit/examples/fetch_json/candid.did @@ -0,0 +1,3 @@ +service : { + fetch_quote : () -> (text); +}; diff --git a/src/ic-cdk-http-kit/examples/fetch_json/dfx.json b/src/ic-cdk-http-kit/examples/fetch_json/dfx.json new file mode 100644 index 000000000..320450a31 --- /dev/null +++ b/src/ic-cdk-http-kit/examples/fetch_json/dfx.json @@ -0,0 +1,18 @@ +{ + "dfx": "0.13.1", + "version": 1, + "canisters": { + "fetch_json": { + "type": "rust", + "package": "fetch_json", + "candid": "candid.did" + } + }, + "defaults": { + "build": { + "packtool": "", + "args": "" + } + }, + "output_env_file": ".env" +} \ No newline at end of file diff --git a/src/ic-cdk-http-kit/examples/fetch_json/e2e-tests/fetch_quote.sh b/src/ic-cdk-http-kit/examples/fetch_json/e2e-tests/fetch_quote.sh new file mode 100755 index 000000000..a87e87028 --- /dev/null +++ b/src/ic-cdk-http-kit/examples/fetch_json/e2e-tests/fetch_quote.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# +# A test that verifies that the `fetch_quote` endpoint works as expected. + +# Run dfx stop if we run into errors. +trap "dfx stop" EXIT SIGINT + +dfx start --background --clean + +# Deploy the watchdog canister. +dfx deploy --no-wallet fetch_json + +# Request config. +result=$(dfx canister call fetch_json fetch_quote) +echo "Result: $result" + +# Check that the config is correct, eg. by checking it has min_explores field. +if ! [[ $result == *"Kevin Kruse"* ]]; then + echo "FAIL" + exit 1 +fi + +echo "SUCCESS" +exit 0 diff --git a/src/ic-cdk-http-kit/examples/fetch_json/src/main.rs b/src/ic-cdk-http-kit/examples/fetch_json/src/main.rs new file mode 100644 index 000000000..bbf9f2b12 --- /dev/null +++ b/src/ic-cdk-http-kit/examples/fetch_json/src/main.rs @@ -0,0 +1,129 @@ +use ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpResponse, TransformArgs, +}; + +/// Transform the response body by extracting the author from the JSON response. +#[ic_cdk_macros::query] +fn transform_quote(raw: TransformArgs) -> HttpResponse { + let mut response = HttpResponse { + status: raw.response.status.clone(), + ..Default::default() + }; + if response.status == 200 { + let original = parse_json(raw.response.body); + let transformed = original["author"].as_str().unwrap_or_default(); + response.body = transformed.to_string().into_bytes(); + } else { + print(&format!("Transform error: err = {:?}", raw)); + } + response +} + +/// Create a quote request with transformation function. +fn build_quote_request(url: &str) -> CanisterHttpRequestArgument { + ic_cdk_http_kit::create_request() + .get(url) + .header( + "User-Agent".to_string(), + "ic-http-outcall-kit-example".to_string(), + ) + .transform(transform_quote, vec![]) + .build() +} + +/// Fetch data by making an HTTP request. +async fn fetch(request: CanisterHttpRequestArgument) -> String { + match ic_cdk_http_kit::http_request(request).await { + Ok((response,)) => { + if response.status == 200 { + format!("Response: {:?}", String::from_utf8(response.body).unwrap()) + } else { + format!("Unexpected status: {:?}", response.status) + } + } + Err((code, msg)) => { + format!("Error: {:?} {:?}", code, msg) + } + } +} + +/// Fetch a quote from the dummyjson.com API. +#[ic_cdk_macros::update] +async fn fetch_quote() -> String { + let request = build_quote_request("https://dummyjson.com/quotes/1"); + fetch(request).await +} + +/// Parse the raw response body as JSON. +fn parse_json(body: Vec) -> serde_json::Value { + let json_str = String::from_utf8(body).expect("Raw response is not UTF-8 encoded."); + serde_json::from_str(&json_str).expect("Failed to parse JSON from string") +} + +/// Print a message to the console. +fn print(msg: &str) { + #[cfg(target_arch = "wasm32")] + ic_cdk::api::print(msg); + + #[cfg(not(target_arch = "wasm32"))] + println!("{}", msg); +} + +fn main() {} + +#[cfg(test)] +mod test { + use super::*; + use ic_cdk::api::call::RejectionCode; + + // Test http_request returns an author after modifying the response body. + #[tokio::test] + async fn test_http_request_transform_body_quote() { + // Arrange + let request = build_quote_request("https://dummyjson.com/quotes/1"); + let mock_response = ic_cdk_http_kit::create_response() + .status(200) + .body(r#"{"quote": "Be yourself; everyone else is taken.", "author": "Oscar Wilde"}"#) + .build(); + ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); + + // Act + let result = fetch(request.clone()).await; + + // Assert + assert_eq!(result, r#"Response: "Oscar Wilde""#.to_string()); + assert_eq!(ic_cdk_http_kit::times_called(request), 1); + } + + // Test http_request returns a system fatal error. + #[tokio::test] + async fn test_http_request_transform_body_quote_error() { + // Arrange + let request = build_quote_request("https://dummyjson.com/quotes/1"); + let mock_error = (RejectionCode::SysFatal, "fatal".to_string()); + ic_cdk_http_kit::mock(request.clone(), Err(mock_error)); + + // Act + let result = fetch(request.clone()).await; + + // Assert + assert_eq!(result, r#"Error: SysFatal "fatal""#.to_string()); + assert_eq!(ic_cdk_http_kit::times_called(request), 1); + } + + // Test http_request returns a response with status 404. + #[tokio::test] + async fn test_http_request_transform_body_quote_404() { + // Arrange + let request = build_quote_request("https://dummyjson.com/quotes/1"); + let mock_response = ic_cdk_http_kit::create_response().status(404).build(); + ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); + + // Act + let result = fetch(request.clone()).await; + + // Assert + assert_eq!(result, "Unexpected status: Nat(404)".to_string()); + assert_eq!(ic_cdk_http_kit::times_called(request), 1); + } +} From 338770f8f63568b6daf358028158ab084f37d50c Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Thu, 4 May 2023 13:24:07 +0200 Subject: [PATCH 03/20] generate readme --- src/ic-cdk-http-kit/README.md | 86 ++++++++++++++++++++++ src/ic-cdk-http-kit/scripts/test_readme.sh | 19 +++++ src/ic-cdk-http-kit/src/lib.rs | 18 ++--- 3 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 src/ic-cdk-http-kit/README.md create mode 100755 src/ic-cdk-http-kit/scripts/test_readme.sh diff --git a/src/ic-cdk-http-kit/README.md b/src/ic-cdk-http-kit/README.md new file mode 100644 index 000000000..fadb7305c --- /dev/null +++ b/src/ic-cdk-http-kit/README.md @@ -0,0 +1,86 @@ +# ic-cdk-http-kit + +A simple toolkit for constructing and testing HTTP Outcalls on the Internet Computer. + +It streamlines unit testing of HTTP Outcalls and provides user-friendly utilities. +The crate simulates the `http_request` function from `ic_cdk` by retrieving mock responses, checking the maximum allowed size, and applying a transformation function if specified, optionally with a delay to simulate latency. + +Note: To properly simulate the transformation function inside `ic_cdk_http_kit::http_request`, the request builder must be used. + +### Features + +- Simple interface for creating HTTP requests and responses +- Support for HTTP response transformation functions +- Control over response size with a maximum byte limit +- Mock response with optional delay to simulate latency +- Assert the number of times a request was called + +### Examples + +#### Creating a Request + +```rust +fn transform_function(arg: TransformArgs) -> HttpResponse { + // Modify arg.response here + arg.response +} + +let request = ic_cdk_http_kit::create_request() + .get("https://dummyjson.com/todos/1") + .max_response_bytes(1_024) + .transform(transform_function, vec![]) + .build(); +``` + +#### Creating a Response + +```rust +let mock_response = ic_cdk_http_kit::create_response() + .status(200) + .body("some text") + .build(); +``` + +#### Mocking + +```rust +ic_cdk_http_kit::mock(request, Ok(mock_response)); +ic_cdk_http_kit::mock_with_delay(request, Ok(mock_response), Duration::from_sec(2)); + +let mock_error = (RejectionCode::SysFatal, "system fatal error".to_string()); +ic_cdk_http_kit::mock(request, Err(mock_error)); +ic_cdk_http_kit::mock_with_delay(request, Err(mock_error), Duration::from_sec(2)); +``` + +#### Making an HTTP Outcall + +```rust +let (response,) = ic_cdk_http_kit::http_request(request).await.unwrap(); +``` + +#### Asserts + +```rust +assert_eq!(response.status, 200); +assert_eq!(response.body, "transformed body".to_owned().into_bytes()); +assert_eq!(ic_cdk_http_kit::times_called(request), 1); +``` + +#### More Examples + +Please refer to the provided usage examples in the [tests](./tests) or [examples](./examples) directories. + +### Contributing + +Please follow the guidelines in the [CONTRIBUTING.md](.github/CONTRIBUTING.md) document. + +### References + +- [Integrations](https://internetcomputer.org/docs/current/developer-docs/integrations/) +- [HTTPS Outcalls](https://internetcomputer.org/docs/current/developer-docs/integrations/http_requests/) +- HTTP Outcalls, [IC method http_request](https://internetcomputer.org/docs/current/references/ic-interface-spec#ic-http_request) +- Serving HTTP responses, [The HTTP Gateway protocol](https://internetcomputer.org/docs/current/references/ic-interface-spec#http-gateway) +- [Transformation Function](https://internetcomputer.org/docs/current/developer-docs/integrations/http_requests/http_requests-how-it-works#transformation-function) + + +License: Apache-2.0 diff --git a/src/ic-cdk-http-kit/scripts/test_readme.sh b/src/ic-cdk-http-kit/scripts/test_readme.sh new file mode 100755 index 000000000..813e373a7 --- /dev/null +++ b/src/ic-cdk-http-kit/scripts/test_readme.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Generate a temporary README file +cargo readme > readme_tmp.md + +# Compare the temporary README file to the existing README.md +difference=$(diff -u --ignore-all-space README.md readme_tmp.md) + +if [ -n "$difference" ]; then + echo "[ FAIL ] README.md and generated readme_tmp.md are different:" + echo "$difference" + echo "Use 'cargo readme > README.md' to update README.md" + rm readme_tmp.md + exit 1 +else + echo "[ OK ] README.md and generated readme_tmp.md match" + rm readme_tmp.md + exit 0 +fi diff --git a/src/ic-cdk-http-kit/src/lib.rs b/src/ic-cdk-http-kit/src/lib.rs index e153d87e2..058b84e6e 100644 --- a/src/ic-cdk-http-kit/src/lib.rs +++ b/src/ic-cdk-http-kit/src/lib.rs @@ -3,7 +3,7 @@ //! It streamlines unit testing of HTTP Outcalls and provides user-friendly utilities. //! The crate simulates the `http_request` function from `ic_cdk` by retrieving mock responses, checking the maximum allowed size, and applying a transformation function if specified, optionally with a delay to simulate latency. //! -//! Note: To properly simulate the transformation function inside `ic_http_outcall_kit::http_request`, the request builder must be used. +//! Note: To properly simulate the transformation function inside `ic_cdk_http_kit::http_request`, the request builder must be used. //! //! ## Features //! @@ -23,7 +23,7 @@ //! arg.response //! } //! -//! let request = ic_http_outcall_kit::create_request() +//! let request = ic_cdk_http_kit::create_request() //! .get("https://dummyjson.com/todos/1") //! .max_response_bytes(1_024) //! .transform(transform_function, vec![]) @@ -33,7 +33,7 @@ //! ### Creating a Response //! //! ```ignore -//! let mock_response = ic_http_outcall_kit::create_response() +//! let mock_response = ic_cdk_http_kit::create_response() //! .status(200) //! .body("some text") //! .build(); @@ -42,18 +42,18 @@ //! ### Mocking //! //! ```ignore -//! ic_http_outcall_kit::mock(request, Ok(mock_response)); -//! ic_http_outcall_kit::mock_with_delay(request, Ok(mock_response), Duration::from_sec(2)); +//! ic_cdk_http_kit::mock(request, Ok(mock_response)); +//! ic_cdk_http_kit::mock_with_delay(request, Ok(mock_response), Duration::from_sec(2)); //! //! let mock_error = (RejectionCode::SysFatal, "system fatal error".to_string()); -//! ic_http_outcall_kit::mock(request, Err(mock_error)); -//! ic_http_outcall_kit::mock_with_delay(request, Err(mock_error), Duration::from_sec(2)); +//! ic_cdk_http_kit::mock(request, Err(mock_error)); +//! ic_cdk_http_kit::mock_with_delay(request, Err(mock_error), Duration::from_sec(2)); //! ``` //! //! ### Making an HTTP Outcall //! //! ```ignore -//! let (response,) = ic_http_outcall_kit::http_request(request).await.unwrap(); +//! let (response,) = ic_cdk_http_kit::http_request(request).await.unwrap(); //! ``` //! //! ### Asserts @@ -61,7 +61,7 @@ //! ```ignore //! assert_eq!(response.status, 200); //! assert_eq!(response.body, "transformed body".to_owned().into_bytes()); -//! assert_eq!(ic_http_outcall_kit::times_called(request), 1); +//! assert_eq!(ic_cdk_http_kit::times_called(request), 1); //! ``` //! //! ### More Examples From b9a85a34854fbb8313624c3e2f8b4606d73d5312 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Thu, 4 May 2023 13:46:04 +0200 Subject: [PATCH 04/20] add run_all_tests, license, update cargo.toml --- src/ic-cdk-http-kit/Cargo.toml | 26 +++- src/ic-cdk-http-kit/LICENSE | 201 +++++++++++++++++++++++++++ src/ic-cdk-http-kit/run_all_tests.sh | 32 +++++ 3 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 src/ic-cdk-http-kit/LICENSE create mode 100755 src/ic-cdk-http-kit/run_all_tests.sh diff --git a/src/ic-cdk-http-kit/Cargo.toml b/src/ic-cdk-http-kit/Cargo.toml index 11a3c8552..f0b00565d 100644 --- a/src/ic-cdk-http-kit/Cargo.toml +++ b/src/ic-cdk-http-kit/Cargo.toml @@ -1,16 +1,28 @@ [package] name = "ic-cdk-http-kit" -description = "A simple toolkit for constructing and testing HTTP Outcalls on the Internet Computer" version = "0.1.0" +authors = ["DFINITY Stiftung "] edition = "2021" -authors = ["DFINITY Team", "The Internet Computer Project Developers", "Maksym Arutyunyan"] -repository = "https://github.com/dfinity/cdk-rs/ic-cdk-http-kit" -homepage = "https://github.com/dfinity/cdk-rs/ic-cdk-http-kit" +description = "A simple toolkit for constructing and testing HTTP Outcalls on the Internet Computer" +homepage = "https://docs.rs/ic-cdk-http-kit" documentation = "https://docs.rs/ic-cdk-http-kit" -readme = "README.md" license = "Apache-2.0" -keywords = ["http", "http-outcalls", "mock", "canister", "internet-computer"] -categories = ["development-tools::testing", "web-programming", "internet-computer"] +readme = "README.md" +categories = [ + "development-tools::testing", + "web-programming", + "internet-computer", +] +keywords = [ + "http", + "http-outcalls", + "mock", + "canister", + "internet-computer", +] +include = ["src", "Cargo.toml", "LICENSE", "README.md"] +repository = "https://github.com/dfinity/cdk-rs/ic-cdk-http-kit" +rust-version = "1.65.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/ic-cdk-http-kit/LICENSE b/src/ic-cdk-http-kit/LICENSE new file mode 100644 index 000000000..2b0f0d371 --- /dev/null +++ b/src/ic-cdk-http-kit/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 DFINITY LLC. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/ic-cdk-http-kit/run_all_tests.sh b/src/ic-cdk-http-kit/run_all_tests.sh new file mode 100755 index 000000000..bb81e508c --- /dev/null +++ b/src/ic-cdk-http-kit/run_all_tests.sh @@ -0,0 +1,32 @@ +#!/bin/sh + +set -e + +# Check if README.md is up-to-date. +echo "Checking if README.md is up-to-date..." +./scripts/test_readme.sh +echo "README.md is up-to-date." + +# Run cargo tests for the crate. +echo "Running cargo tests for the crate..." +cargo test +echo "Cargo tests for the crate passed." + +# Run cargo tests for example projects. +echo "Running cargo tests for example projects..." +( + cd examples + cargo test +) +echo "Cargo tests for example projects passed." + +# Run dfx end-to-end tests for specific example projects. +echo "Running dfx end-to-end tests for specific example projects..." +( + cd examples/fetch_json + e2e-tests/fetch_quote.sh +) +echo "dfx end-to-end tests for specific example projects passed." + +# All tests passed +echo "All tests passed successfully." From 05253a96a9aae5866dee69e01f1f7a92b8feeb0c Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 5 May 2023 10:38:46 +0200 Subject: [PATCH 05/20] fix transform context --- src/ic-cdk-http-kit/README.md | 2 +- .../examples/fetch_json/src/main.rs | 4 +- src/ic-cdk-http-kit/src/lib.rs | 2 +- src/ic-cdk-http-kit/src/mock.rs | 3 +- src/ic-cdk-http-kit/src/response.rs | 8 ++- src/ic-cdk-http-kit/tests/api.rs | 50 +++++++++++++++---- 6 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/ic-cdk-http-kit/README.md b/src/ic-cdk-http-kit/README.md index fadb7305c..4e3771beb 100644 --- a/src/ic-cdk-http-kit/README.md +++ b/src/ic-cdk-http-kit/README.md @@ -37,7 +37,7 @@ let request = ic_cdk_http_kit::create_request() ```rust let mock_response = ic_cdk_http_kit::create_response() .status(200) - .body("some text") + .body_str("some text") .build(); ``` diff --git a/src/ic-cdk-http-kit/examples/fetch_json/src/main.rs b/src/ic-cdk-http-kit/examples/fetch_json/src/main.rs index bbf9f2b12..fb46325e0 100644 --- a/src/ic-cdk-http-kit/examples/fetch_json/src/main.rs +++ b/src/ic-cdk-http-kit/examples/fetch_json/src/main.rs @@ -83,7 +83,9 @@ mod test { let request = build_quote_request("https://dummyjson.com/quotes/1"); let mock_response = ic_cdk_http_kit::create_response() .status(200) - .body(r#"{"quote": "Be yourself; everyone else is taken.", "author": "Oscar Wilde"}"#) + .body_str( + r#"{"quote": "Be yourself; everyone else is taken.", "author": "Oscar Wilde"}"#, + ) .build(); ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); diff --git a/src/ic-cdk-http-kit/src/lib.rs b/src/ic-cdk-http-kit/src/lib.rs index 058b84e6e..0ac39d002 100644 --- a/src/ic-cdk-http-kit/src/lib.rs +++ b/src/ic-cdk-http-kit/src/lib.rs @@ -35,7 +35,7 @@ //! ```ignore //! let mock_response = ic_cdk_http_kit::create_response() //! .status(200) -//! .body("some text") +//! .body_str("some text") //! .build(); //! ``` //! diff --git a/src/ic-cdk-http-kit/src/mock.rs b/src/ic-cdk-http-kit/src/mock.rs index 29cab7c0f..cd84c5220 100644 --- a/src/ic-cdk-http-kit/src/mock.rs +++ b/src/ic-cdk-http-kit/src/mock.rs @@ -117,11 +117,12 @@ async fn mock_http_request( } // Apply the transform function if one is specified. + let context = mock.request.clone().transform.map_or(vec![], |f| f.context); let transformed_response = call_transform_function( mock.request, TransformArgs { response: mock_response.clone(), - context: vec![], + context, }, ) .unwrap_or(mock_response); diff --git a/src/ic-cdk-http-kit/src/response.rs b/src/ic-cdk-http-kit/src/response.rs index 3663323c9..03917e3ac 100644 --- a/src/ic-cdk-http-kit/src/response.rs +++ b/src/ic-cdk-http-kit/src/response.rs @@ -34,7 +34,13 @@ impl HttpResponseBuilder { } /// Sets the HTTP response body. - pub fn body(mut self, body: &str) -> Self { + pub fn body(mut self, body: Vec) -> Self { + self.0.body = body; + self + } + + /// Sets the HTTP response body text. + pub fn body_str(mut self, body: &str) -> Self { self.0.body = body.as_bytes().to_vec(); self } diff --git a/src/ic-cdk-http-kit/tests/api.rs b/src/ic-cdk-http-kit/tests/api.rs index 49f1ffd5d..14ab450a5 100644 --- a/src/ic-cdk-http-kit/tests/api.rs +++ b/src/ic-cdk-http-kit/tests/api.rs @@ -16,7 +16,7 @@ async fn test_http_request_no_transform() { .build(); let mock_response = ic_cdk_http_kit::create_response() .status(STATUS_CODE_OK) - .body(body) + .body_str(body) .build(); ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); @@ -41,7 +41,7 @@ async fn test_http_request_called_several_times() { .build(); let mock_response = ic_cdk_http_kit::create_response() .status(STATUS_CODE_OK) - .body(body) + .body_str(body) .build(); ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); @@ -72,7 +72,7 @@ async fn test_http_request_transform_status() { .build(); let mock_response = ic_cdk_http_kit::create_response() .status(STATUS_CODE_OK) - .body("some text") + .body_str("some text") .build(); ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); @@ -93,7 +93,7 @@ async fn test_http_request_transform_body() { const TRANSFORMED_BODY: &str = "transformed body"; fn transform(_arg: TransformArgs) -> HttpResponse { ic_cdk_http_kit::create_response() - .body(TRANSFORMED_BODY) + .body_str(TRANSFORMED_BODY) .build() } let request = ic_cdk_http_kit::create_request() @@ -102,7 +102,7 @@ async fn test_http_request_transform_body() { .build(); let mock_response = ic_cdk_http_kit::create_response() .status(STATUS_CODE_OK) - .body(ORIGINAL_BODY) + .body_str(ORIGINAL_BODY) .build(); ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); @@ -116,6 +116,38 @@ async fn test_http_request_transform_body() { assert_eq!(ic_cdk_http_kit::times_called(request), 1); } +#[tokio::test] +async fn test_http_request_transform_context() { + // Arrange + fn transform_context_to_body_text(arg: TransformArgs) -> HttpResponse { + HttpResponse { + body: arg.context, + ..arg.response + } + } + let request = ic_cdk_http_kit::create_request() + .get("https://dummyjson.com/todos/1") + .transform( + transform_context_to_body_text, + "some context".as_bytes().to_vec(), + ) + .build(); + let mock_response = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_OK) + .body_str("some context") + .build(); + ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); + + // Act + let (response,) = ic_cdk_http_kit::http_request(request.clone()) + .await + .unwrap(); + + // Assert + assert_eq!(response.body, "some context".as_bytes().to_vec()); + assert_eq!(ic_cdk_http_kit::times_called(request), 1); +} + #[tokio::test] async fn test_http_request_transform_both_status_and_body() { // Arrange @@ -140,7 +172,7 @@ async fn test_http_request_transform_both_status_and_body() { .build(); let mock_response_1 = ic_cdk_http_kit::create_response() .status(STATUS_CODE_NOT_FOUND) - .body(ORIGINAL_BODY) + .body_str(ORIGINAL_BODY) .build(); ic_cdk_http_kit::mock(request_1.clone(), Ok(mock_response_1)); @@ -150,7 +182,7 @@ async fn test_http_request_transform_both_status_and_body() { .build(); let mock_response_2 = ic_cdk_http_kit::create_response() .status(STATUS_CODE_OK) - .body(TRANSFORMED_BODY) + .body_str(TRANSFORMED_BODY) .build(); ic_cdk_http_kit::mock(request_2.clone(), Ok(mock_response_2)); @@ -191,7 +223,7 @@ async fn test_http_request_max_response_bytes_ok() { .build(); let mock_response = ic_cdk_http_kit::create_response() .status(STATUS_CODE_OK) - .body(body_small_enough) + .body_str(body_small_enough) .build(); ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); @@ -214,7 +246,7 @@ async fn test_http_request_max_response_bytes_error() { .build(); let mock_response = ic_cdk_http_kit::create_response() .status(STATUS_CODE_OK) - .body(body_too_big) + .body_str(body_too_big) .build(); ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); From 80c65a6acfb25a171d4b669dec7b16e4cc57c1c4 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 5 May 2023 10:40:41 +0200 Subject: [PATCH 06/20] cleanup --- src/ic-cdk-http-kit/tests/api.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ic-cdk-http-kit/tests/api.rs b/src/ic-cdk-http-kit/tests/api.rs index 14ab450a5..00be25ef8 100644 --- a/src/ic-cdk-http-kit/tests/api.rs +++ b/src/ic-cdk-http-kit/tests/api.rs @@ -133,7 +133,6 @@ async fn test_http_request_transform_context() { ) .build(); let mock_response = ic_cdk_http_kit::create_response() - .status(STATUS_CODE_OK) .body_str("some context") .build(); ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); From 4b361c1a4d1c9615f342c1ab903412ec3a0c168c Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Wed, 10 May 2023 10:43:44 +0200 Subject: [PATCH 07/20] add http_request_with_cycles --- src/ic-cdk-http-kit/Cargo.toml | 4 ++-- .../examples/fetch_json/Cargo.toml | 4 ++-- src/ic-cdk-http-kit/src/mock.rs | 22 +++++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/ic-cdk-http-kit/Cargo.toml b/src/ic-cdk-http-kit/Cargo.toml index f0b00565d..282020b27 100644 --- a/src/ic-cdk-http-kit/Cargo.toml +++ b/src/ic-cdk-http-kit/Cargo.toml @@ -28,8 +28,8 @@ rust-version = "1.65.0" [dependencies] candid = "0.8.2" -ic-cdk = "0.6.0" -ic-cdk-macros = "0.6.0" +ic-cdk = "0.7.4" +ic-cdk-macros = "0.6.10" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { version = "1.15.0", features = [ "full" ] } diff --git a/src/ic-cdk-http-kit/examples/fetch_json/Cargo.toml b/src/ic-cdk-http-kit/examples/fetch_json/Cargo.toml index d3a5000c7..8145bbef7 100644 --- a/src/ic-cdk-http-kit/examples/fetch_json/Cargo.toml +++ b/src/ic-cdk-http-kit/examples/fetch_json/Cargo.toml @@ -11,8 +11,8 @@ path = "src/main.rs" [dependencies] candid = "0.8.2" -ic-cdk = "0.6.0" -ic-cdk-macros = "0.6.0" +ic-cdk = "0.7.4" +ic-cdk-macros = "0.6.10" ic-cdk-http-kit = { path = "../../" } serde = { version = "1.0.158", features = [ "derive" ] } serde_json = "1.0.94" diff --git a/src/ic-cdk-http-kit/src/mock.rs b/src/ic-cdk-http-kit/src/mock.rs index cd84c5220..e2ecfaeb1 100644 --- a/src/ic-cdk-http-kit/src/mock.rs +++ b/src/ic-cdk-http-kit/src/mock.rs @@ -77,6 +77,28 @@ pub async fn http_request(arg: CanisterHttpRequestArgument) -> CallResult<(HttpR } } +/// Make an HTTP request to a given URL and return the HTTP response, possibly after a transformation. +/// +/// This is a helper function that compiles differently depending on the target architecture. +/// For wasm32 (assuming a canister in prod), it calls the IC method `http_request_with_cycles`. +/// For other architectures, it calls a mock function. +pub async fn http_request_with_cycles( + arg: CanisterHttpRequestArgument, + cycles: u128, +) -> CallResult<(HttpResponse,)> { + #[cfg(target_arch = "wasm32")] + { + ic_cdk::api::management_canister::http_request::http_request_with_cycles(arg, cycles).await + } + + #[cfg(not(target_arch = "wasm32"))] + { + // Mocking cycles is not implemented at the moment. + let _unused = cycles; + mock_http_request(arg).await + } +} + /// Handles incoming HTTP requests by retrieving a mock response based /// on the request, possibly delaying the response, transforming the response if necessary, /// and returning it. If there is no mock found, it returns an error. From ae90d515f606b57b860ee02edf67cf4c210121cf Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Wed, 10 May 2023 11:05:31 +0200 Subject: [PATCH 08/20] cleanup crate documentation --- src/ic-cdk-http-kit/Cargo.toml | 1 - src/ic-cdk-http-kit/README.md | 12 ++++-------- src/ic-cdk-http-kit/src/lib.rs | 30 ++++++++++++++++++------------ 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/ic-cdk-http-kit/Cargo.toml b/src/ic-cdk-http-kit/Cargo.toml index 282020b27..6595b27aa 100644 --- a/src/ic-cdk-http-kit/Cargo.toml +++ b/src/ic-cdk-http-kit/Cargo.toml @@ -11,7 +11,6 @@ readme = "README.md" categories = [ "development-tools::testing", "web-programming", - "internet-computer", ] keywords = [ "http", diff --git a/src/ic-cdk-http-kit/README.md b/src/ic-cdk-http-kit/README.md index 4e3771beb..56ea0dc99 100644 --- a/src/ic-cdk-http-kit/README.md +++ b/src/ic-cdk-http-kit/README.md @@ -44,12 +44,12 @@ let mock_response = ic_cdk_http_kit::create_response() #### Mocking ```rust -ic_cdk_http_kit::mock(request, Ok(mock_response)); -ic_cdk_http_kit::mock_with_delay(request, Ok(mock_response), Duration::from_sec(2)); +ic_cdk_http_kit::mock(request.clone(), Ok(mock_response.clone())); +ic_cdk_http_kit::mock_with_delay(request.clone(), Ok(mock_response.clone()), Duration::from_secs(2)); let mock_error = (RejectionCode::SysFatal, "system fatal error".to_string()); -ic_cdk_http_kit::mock(request, Err(mock_error)); -ic_cdk_http_kit::mock_with_delay(request, Err(mock_error), Duration::from_sec(2)); +ic_cdk_http_kit::mock(request.clone(), Err(mock_error.clone())); +ic_cdk_http_kit::mock_with_delay(request.clone(), Err(mock_error.clone()), Duration::from_secs(2)); ``` #### Making an HTTP Outcall @@ -70,10 +70,6 @@ assert_eq!(ic_cdk_http_kit::times_called(request), 1); Please refer to the provided usage examples in the [tests](./tests) or [examples](./examples) directories. -### Contributing - -Please follow the guidelines in the [CONTRIBUTING.md](.github/CONTRIBUTING.md) document. - ### References - [Integrations](https://internetcomputer.org/docs/current/developer-docs/integrations/) diff --git a/src/ic-cdk-http-kit/src/lib.rs b/src/ic-cdk-http-kit/src/lib.rs index 0ac39d002..c95c14d5a 100644 --- a/src/ic-cdk-http-kit/src/lib.rs +++ b/src/ic-cdk-http-kit/src/lib.rs @@ -17,7 +17,8 @@ //! //! ### Creating a Request //! -//! ```ignore +//! ```rust +//! # use ic_cdk::api::management_canister::http_request::{TransformArgs, HttpResponse}; //! fn transform_function(arg: TransformArgs) -> HttpResponse { //! // Modify arg.response here //! arg.response @@ -32,7 +33,7 @@ //! //! ### Creating a Response //! -//! ```ignore +//! ```rust //! let mock_response = ic_cdk_http_kit::create_response() //! .status(200) //! .body_str("some text") @@ -41,24 +42,33 @@ //! //! ### Mocking //! -//! ```ignore -//! ic_cdk_http_kit::mock(request, Ok(mock_response)); -//! ic_cdk_http_kit::mock_with_delay(request, Ok(mock_response), Duration::from_sec(2)); +//! ```rust +//! # use std::time::Duration; +//! # use ic_cdk::api::call::RejectionCode; +//! # let request = ic_cdk_http_kit::create_request().build(); +//! # let mock_response = ic_cdk_http_kit::create_response().build(); +//! ic_cdk_http_kit::mock(request.clone(), Ok(mock_response.clone())); +//! ic_cdk_http_kit::mock_with_delay(request.clone(), Ok(mock_response.clone()), Duration::from_secs(2)); //! //! let mock_error = (RejectionCode::SysFatal, "system fatal error".to_string()); -//! ic_cdk_http_kit::mock(request, Err(mock_error)); -//! ic_cdk_http_kit::mock_with_delay(request, Err(mock_error), Duration::from_sec(2)); +//! ic_cdk_http_kit::mock(request.clone(), Err(mock_error.clone())); +//! ic_cdk_http_kit::mock_with_delay(request.clone(), Err(mock_error.clone()), Duration::from_secs(2)); //! ``` //! //! ### Making an HTTP Outcall //! //! ```ignore +//! # // Ignored since this is an async function. //! let (response,) = ic_cdk_http_kit::http_request(request).await.unwrap(); //! ``` //! //! ### Asserts //! -//! ```ignore +//! ```no_run +//! # // `no_run` since it would require to call an async function to count the number of calls. +//! # use ic_cdk::api::management_canister::http_request::HttpResponse; +//! # let request = ic_cdk_http_kit::create_request().build(); +//! # let response = ic_cdk_http_kit::create_response().build(); //! assert_eq!(response.status, 200); //! assert_eq!(response.body, "transformed body".to_owned().into_bytes()); //! assert_eq!(ic_cdk_http_kit::times_called(request), 1); @@ -68,10 +78,6 @@ //! //! Please refer to the provided usage examples in the [tests](./tests) or [examples](./examples) directories. //! -//! ## Contributing -//! -//! Please follow the guidelines in the [CONTRIBUTING.md](.github/CONTRIBUTING.md) document. -//! //! ## References //! //! - [Integrations](https://internetcomputer.org/docs/current/developer-docs/integrations/) From a582289a7f07b9293d1785dcf5fe3c1c32cb3a03 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Wed, 10 May 2023 11:11:01 +0200 Subject: [PATCH 09/20] switch to RefCell --- src/ic-cdk-http-kit/src/storage.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/ic-cdk-http-kit/src/storage.rs b/src/ic-cdk-http-kit/src/storage.rs index 9c23f6b7f..1cfb1f7f0 100644 --- a/src/ic-cdk-http-kit/src/storage.rs +++ b/src/ic-cdk-http-kit/src/storage.rs @@ -2,25 +2,25 @@ use crate::mock::{hash, Mock}; use ic_cdk::api::management_canister::http_request::{ CanisterHttpRequestArgument, HttpResponse, TransformArgs, }; +use std::cell::RefCell; use std::collections::HashMap; -use std::sync::RwLock; thread_local! { - static MOCKS: RwLock> = RwLock::new(HashMap::new()); + static MOCKS: RefCell> = RefCell::new(HashMap::new()); - static TRANSFORM_FUNCTIONS: RwLock>> = RwLock::new(HashMap::new()); + static TRANSFORM_FUNCTIONS: RefCell>> = RefCell::new(HashMap::new()); } /// Inserts the provided mock into a thread-local hashmap. pub(crate) fn mock_insert(mock: Mock) { MOCKS.with(|cell| { - cell.write().unwrap().insert(hash(&mock.request), mock); + cell.borrow_mut().insert(hash(&mock.request), mock); }); } /// Returns a cloned mock from the thread-local hashmap that corresponds to the provided request. pub(crate) fn mock_get(request: &CanisterHttpRequestArgument) -> Option { - MOCKS.with(|cell| cell.read().unwrap().get(&hash(request)).cloned()) + MOCKS.with(|cell| cell.borrow().get(&hash(request)).cloned()) } type TransformFn = dyn Fn(TransformArgs) -> HttpResponse + 'static; @@ -31,21 +31,21 @@ pub(crate) fn transform_function_insert(name: String, func: Box) { TRANSFORM_FUNCTIONS.with(|cell| { // This is a workaround to prevent the transform function from being // overridden while it is being executed. - if cell.read().unwrap().get(&name).is_none() { - cell.write().unwrap().insert(name, func); + if cell.borrow().get(&name).is_none() { + cell.borrow_mut().insert(name, func); } }); } /// Executes the transform function that corresponds to the provided name. pub(crate) fn transform_function_call(name: String, arg: TransformArgs) -> Option { - TRANSFORM_FUNCTIONS.with(|cell| cell.read().unwrap().get(&name).map(|f| f(arg))) + TRANSFORM_FUNCTIONS.with(|cell| cell.borrow().get(&name).map(|f| f(arg))) } /// Returns a sorted list of transform function names. pub(crate) fn transform_function_names() -> Vec { TRANSFORM_FUNCTIONS.with(|cell| { - let mut names: Vec = cell.read().unwrap().keys().cloned().collect(); + let mut names: Vec = cell.borrow().keys().cloned().collect(); names.sort(); names }) From 1dde57138a9e9843406a7ba0f5db51de30d876b1 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Thu, 11 May 2023 17:49:32 +0200 Subject: [PATCH 10/20] accommodate for TransformContext::from_name --- src/ic-cdk-http-kit/Cargo.toml | 4 +- src/ic-cdk-http-kit/README.md | 2 +- .../examples/fetch_json/Cargo.toml | 4 +- .../examples/fetch_json/src/main.rs | 2 +- src/ic-cdk-http-kit/src/lib.rs | 2 +- src/ic-cdk-http-kit/src/mock.rs | 5 -- src/ic-cdk-http-kit/src/request.rs | 77 ++++++------------- src/ic-cdk-http-kit/src/storage.rs | 9 --- src/ic-cdk-http-kit/tests/api.rs | 23 +++--- 9 files changed, 39 insertions(+), 89 deletions(-) diff --git a/src/ic-cdk-http-kit/Cargo.toml b/src/ic-cdk-http-kit/Cargo.toml index 6595b27aa..f73735267 100644 --- a/src/ic-cdk-http-kit/Cargo.toml +++ b/src/ic-cdk-http-kit/Cargo.toml @@ -27,8 +27,8 @@ rust-version = "1.65.0" [dependencies] candid = "0.8.2" -ic-cdk = "0.7.4" -ic-cdk-macros = "0.6.10" +ic-cdk = { path = "../../src/ic-cdk", version = "0.7.4" } +ic-cdk-macros = { path = "../../src/ic-cdk-macros", version = "0.6.10" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { version = "1.15.0", features = [ "full" ] } diff --git a/src/ic-cdk-http-kit/README.md b/src/ic-cdk-http-kit/README.md index 56ea0dc99..5ab828ad1 100644 --- a/src/ic-cdk-http-kit/README.md +++ b/src/ic-cdk-http-kit/README.md @@ -28,7 +28,7 @@ fn transform_function(arg: TransformArgs) -> HttpResponse { let request = ic_cdk_http_kit::create_request() .get("https://dummyjson.com/todos/1") .max_response_bytes(1_024) - .transform(transform_function, vec![]) + .transform("transform_function", transform_function, vec![]) .build(); ``` diff --git a/src/ic-cdk-http-kit/examples/fetch_json/Cargo.toml b/src/ic-cdk-http-kit/examples/fetch_json/Cargo.toml index 8145bbef7..9c7914a1b 100644 --- a/src/ic-cdk-http-kit/examples/fetch_json/Cargo.toml +++ b/src/ic-cdk-http-kit/examples/fetch_json/Cargo.toml @@ -11,8 +11,8 @@ path = "src/main.rs" [dependencies] candid = "0.8.2" -ic-cdk = "0.7.4" -ic-cdk-macros = "0.6.10" +ic-cdk = { path = "../../../../src/ic-cdk", version = "0.7.4" } +ic-cdk-macros = { path = "../../../../src/ic-cdk-macros", version = "0.6.10" } ic-cdk-http-kit = { path = "../../" } serde = { version = "1.0.158", features = [ "derive" ] } serde_json = "1.0.94" diff --git a/src/ic-cdk-http-kit/examples/fetch_json/src/main.rs b/src/ic-cdk-http-kit/examples/fetch_json/src/main.rs index fb46325e0..a66530e89 100644 --- a/src/ic-cdk-http-kit/examples/fetch_json/src/main.rs +++ b/src/ic-cdk-http-kit/examples/fetch_json/src/main.rs @@ -27,7 +27,7 @@ fn build_quote_request(url: &str) -> CanisterHttpRequestArgument { "User-Agent".to_string(), "ic-http-outcall-kit-example".to_string(), ) - .transform(transform_quote, vec![]) + .transform("transform_quote", transform_quote, vec![]) .build() } diff --git a/src/ic-cdk-http-kit/src/lib.rs b/src/ic-cdk-http-kit/src/lib.rs index c95c14d5a..7a2a3280c 100644 --- a/src/ic-cdk-http-kit/src/lib.rs +++ b/src/ic-cdk-http-kit/src/lib.rs @@ -27,7 +27,7 @@ //! let request = ic_cdk_http_kit::create_request() //! .get("https://dummyjson.com/todos/1") //! .max_response_bytes(1_024) -//! .transform(transform_function, vec![]) +//! .transform("transform_function", transform_function, vec![]) //! .build(); //! ``` //! diff --git a/src/ic-cdk-http-kit/src/mock.rs b/src/ic-cdk-http-kit/src/mock.rs index e2ecfaeb1..17acaf774 100644 --- a/src/ic-cdk-http-kit/src/mock.rs +++ b/src/ic-cdk-http-kit/src/mock.rs @@ -55,11 +55,6 @@ pub fn times_called(request: CanisterHttpRequestArgument) -> u64 { .unwrap_or(0) } -/// Returns a sorted list of registered transform function names. -pub fn registered_transform_function_names() -> Vec { - storage::transform_function_names() -} - /// Make an HTTP request to a given URL and return the HTTP response, possibly after a transformation. /// /// This is a helper function that compiles differently depending on the target architecture. diff --git a/src/ic-cdk-http-kit/src/request.rs b/src/ic-cdk-http-kit/src/request.rs index 93d61187f..e4437f801 100644 --- a/src/ic-cdk-http-kit/src/request.rs +++ b/src/ic-cdk-http-kit/src/request.rs @@ -80,11 +80,15 @@ impl CanisterHttpRequestArgumentBuilder { } /// Sets the transform function. - pub fn transform(mut self, func: T, context: Vec) -> Self + pub fn transform(mut self, candid_function_name: &str, func: T, context: Vec) -> Self where T: Fn(TransformArgs) -> HttpResponse + 'static, { - self.0.transform = Some(create_transform_context(func, context)); + self.0.transform = Some(create_transform_context( + candid_function_name.to_string(), + func, + context, + )); self } @@ -100,13 +104,17 @@ impl Default for CanisterHttpRequestArgumentBuilder { } } -fn create_transform_context(func: T, context: Vec) -> TransformContext +fn create_transform_context( + candid_function_name: String, + func: T, + context: Vec, +) -> TransformContext where T: Fn(TransformArgs) -> HttpResponse + 'static, { #[cfg(target_arch = "wasm32")] { - TransformContext::new(func, context) + TransformContext::from_name(candid_function_name, context) } #[cfg(not(target_arch = "wasm32"))] @@ -114,65 +122,24 @@ where // crate::id() can not be called outside of canister, that's why for testing // it is replaced with Principal::management_canister(). let principal = Principal::management_canister(); - let method = get_function_name(&func).to_string(); - super::storage::transform_function_insert(method.clone(), Box::new(func)); + super::storage::transform_function_insert(candid_function_name.clone(), Box::new(func)); TransformContext { - function: TransformFunc(candid::Func { principal, method }), + function: TransformFunc(candid::Func { + principal, + method: candid_function_name, + }), context, } } } -fn get_function_name(_: &F) -> &'static str { - let full_name = std::any::type_name::(); - match full_name.rfind(':') { - Some(index) => &full_name[index + 1..], - None => full_name, - } -} - #[cfg(test)] mod test { - use super::*; use ic_cdk::api::management_canister::http_request::{ CanisterHttpRequestArgument, HttpResponse, TransformArgs, }; - /// A test transform function. - fn transform_function_1(arg: TransformArgs) -> HttpResponse { - arg.response - } - - /// A test transform function. - fn transform_function_2(arg: TransformArgs) -> HttpResponse { - arg.response - } - - /// Inserts the provided transform function into a thread-local hashmap. - fn insert(f: T) - where - T: Fn(TransformArgs) -> HttpResponse + 'static, - { - let name = get_function_name(&f).to_string(); - crate::storage::transform_function_insert(name, Box::new(f)); - } - - /// This test makes sure that transform function names are preserved - /// when passing to the function. - #[test] - fn test_transform_function_names() { - // Arrange. - insert(transform_function_1); - insert(transform_function_2); - - // Act. - let names = crate::mock::registered_transform_function_names(); - - // Assert. - assert_eq!(names, vec!["transform_function_1", "transform_function_2"]); - } - /// Transform function which intentionally creates a new request passing /// itself as the target transform function. fn transform_function_with_overwrite(arg: TransformArgs) -> HttpResponse { @@ -184,7 +151,11 @@ mod test { fn create_request_with_transform() -> CanisterHttpRequestArgument { crate::create_request() .url("https://www.example.com") - .transform(transform_function_with_overwrite, vec![]) + .transform( + "transform_function_with_overwrite", + transform_function_with_overwrite, + vec![], + ) .build() } @@ -208,9 +179,5 @@ mod test { // Assert assert_eq!(response.status, 200); assert_eq!(crate::mock::times_called(request), 1); - assert_eq!( - crate::mock::registered_transform_function_names(), - vec!["transform_function_with_overwrite"] - ); } } diff --git a/src/ic-cdk-http-kit/src/storage.rs b/src/ic-cdk-http-kit/src/storage.rs index 1cfb1f7f0..1a1102fa9 100644 --- a/src/ic-cdk-http-kit/src/storage.rs +++ b/src/ic-cdk-http-kit/src/storage.rs @@ -41,12 +41,3 @@ pub(crate) fn transform_function_insert(name: String, func: Box) { pub(crate) fn transform_function_call(name: String, arg: TransformArgs) -> Option { TRANSFORM_FUNCTIONS.with(|cell| cell.borrow().get(&name).map(|f| f(arg))) } - -/// Returns a sorted list of transform function names. -pub(crate) fn transform_function_names() -> Vec { - TRANSFORM_FUNCTIONS.with(|cell| { - let mut names: Vec = cell.borrow().keys().cloned().collect(); - names.sort(); - names - }) -} diff --git a/src/ic-cdk-http-kit/tests/api.rs b/src/ic-cdk-http-kit/tests/api.rs index 00be25ef8..fa2e40e3a 100644 --- a/src/ic-cdk-http-kit/tests/api.rs +++ b/src/ic-cdk-http-kit/tests/api.rs @@ -68,7 +68,7 @@ async fn test_http_request_transform_status() { } let request = ic_cdk_http_kit::create_request() .get("https://example.com") - .transform(transform, vec![]) + .transform("transform", transform, vec![]) .build(); let mock_response = ic_cdk_http_kit::create_response() .status(STATUS_CODE_OK) @@ -98,7 +98,7 @@ async fn test_http_request_transform_body() { } let request = ic_cdk_http_kit::create_request() .get("https://dummyjson.com/todos/1") - .transform(transform, vec![]) + .transform("transform", transform, vec![]) .build(); let mock_response = ic_cdk_http_kit::create_response() .status(STATUS_CODE_OK) @@ -128,6 +128,7 @@ async fn test_http_request_transform_context() { let request = ic_cdk_http_kit::create_request() .get("https://dummyjson.com/todos/1") .transform( + "transform_context_to_body_text", transform_context_to_body_text, "some context".as_bytes().to_vec(), ) @@ -167,7 +168,7 @@ async fn test_http_request_transform_both_status_and_body() { let request_1 = ic_cdk_http_kit::create_request() .get("https://dummyjson.com/todos/1") - .transform(transform_status, vec![]) + .transform("transform_status", transform_status, vec![]) .build(); let mock_response_1 = ic_cdk_http_kit::create_response() .status(STATUS_CODE_NOT_FOUND) @@ -177,7 +178,7 @@ async fn test_http_request_transform_both_status_and_body() { let request_2 = ic_cdk_http_kit::create_request() .get("https://dummyjson.com/todos/2") - .transform(transform_body, vec![]) + .transform("transform_body", transform_body, vec![]) .build(); let mock_response_2 = ic_cdk_http_kit::create_response() .status(STATUS_CODE_OK) @@ -198,10 +199,6 @@ async fn test_http_request_transform_both_status_and_body() { .collect(); // Assert - assert_eq!( - ic_cdk_http_kit::registered_transform_function_names(), - vec!["transform_body", "transform_status"] - ); assert_eq!(responses.len(), 2); assert_eq!(responses[0].status, STATUS_CODE_NOT_FOUND); assert_eq!(responses[0].body, ORIGINAL_BODY.as_bytes().to_vec()); @@ -391,7 +388,11 @@ fn transform_function_with_overwrite(arg: TransformArgs) -> HttpResponse { fn create_request_with_transform() -> CanisterHttpRequestArgument { ic_cdk_http_kit::create_request() .url("https://www.example.com") - .transform(transform_function_with_overwrite, vec![]) + .transform( + "transform_function_with_overwrite", + transform_function_with_overwrite, + vec![], + ) .build() } @@ -417,8 +418,4 @@ async fn test_transform_function_call_without_a_hang() { // Assert assert_eq!(response.status, 200); assert_eq!(ic_cdk_http_kit::times_called(request), 1); - assert_eq!( - ic_cdk_http_kit::registered_transform_function_names(), - vec!["transform_function_with_overwrite"] - ); } From fe7398b2c98b38d02a37f148d86f593ae9231a13 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Thu, 11 May 2023 18:33:22 +0200 Subject: [PATCH 11/20] prepare for transform-closure --- src/ic-cdk-http-kit/src/mock.rs | 111 +++++++++++++++++++++-------- src/ic-cdk-http-kit/src/storage.rs | 2 +- 2 files changed, 84 insertions(+), 29 deletions(-) diff --git a/src/ic-cdk-http-kit/src/mock.rs b/src/ic-cdk-http-kit/src/mock.rs index 17acaf774..9c5391854 100644 --- a/src/ic-cdk-http-kit/src/mock.rs +++ b/src/ic-cdk-http-kit/src/mock.rs @@ -11,7 +11,7 @@ type MockError = (RejectionCode, String); #[derive(Clone)] pub(crate) struct Mock { - pub(crate) request: CanisterHttpRequestArgument, + pub(crate) arg: CanisterHttpRequestArgument, result: Option>, delay: Duration, times_called: u64, @@ -20,12 +20,12 @@ pub(crate) struct Mock { impl Mock { /// Creates a new mock. pub fn new( - request: CanisterHttpRequestArgument, + arg: CanisterHttpRequestArgument, result: Result, delay: Duration, ) -> Self { Self { - request, + arg, result: Some(result), delay, times_called: 0, @@ -34,23 +34,23 @@ impl Mock { } /// Mocks a HTTP request. -pub fn mock(request: CanisterHttpRequestArgument, result: Result) { - mock_with_delay(request, result, Duration::from_secs(0)); +pub fn mock(arg: CanisterHttpRequestArgument, result: Result) { + mock_with_delay(arg, result, Duration::from_secs(0)); } /// Mocks a HTTP request with a delay. pub fn mock_with_delay( - request: CanisterHttpRequestArgument, + arg: CanisterHttpRequestArgument, result: Result, delay: Duration, ) { - storage::mock_insert(Mock::new(request, result, delay)); + storage::mock_insert(Mock::new(arg, result, delay)); } /// Returns the number of times a HTTP request was called. /// Returns 0 if no mock has been found for the request. -pub fn times_called(request: CanisterHttpRequestArgument) -> u64 { - storage::mock_get(&request) +pub fn times_called(arg: CanisterHttpRequestArgument) -> u64 { + storage::mock_get(&arg) .map(|mock| mock.times_called) .unwrap_or(0) } @@ -68,7 +68,24 @@ pub async fn http_request(arg: CanisterHttpRequestArgument) -> CallResult<(HttpR #[cfg(not(target_arch = "wasm32"))] { - mock_http_request(arg).await + mock_http_request(arg, |response| response).await + } +} + +#[cfg(any(docsrs, feature = "transform-closure"))] +#[cfg_attr(docsrs, doc(cfg(feature = "transform-closure")))] +pub async fn http_request_with( + arg: CanisterHttpRequestArgument, + transform_func: impl FnOnce(HttpResponse) -> HttpResponse + 'static, +) -> CallResult<(HttpResponse,)> { + #[cfg(target_arch = "wasm32")] + { + ic_cdk::api::management_canister::http_request::http_request_with(arg, transform_func).await + } + + #[cfg(not(target_arch = "wasm32"))] + { + mock_http_request_with(arg, transform_func).await } } @@ -90,17 +107,54 @@ pub async fn http_request_with_cycles( { // Mocking cycles is not implemented at the moment. let _unused = cycles; - mock_http_request(arg).await + mock_http_request(arg, |response| response).await } } +#[cfg(any(docsrs, feature = "transform-closure"))] +#[cfg_attr(docsrs, doc(cfg(feature = "transform-closure")))] +pub async fn http_request_with_cycles_with( + arg: CanisterHttpRequestArgument, + cycles: u128, + transform_func: impl FnOnce(HttpResponse) -> HttpResponse + 'static, +) -> CallResult<(HttpResponse,)> { + #[cfg(target_arch = "wasm32")] + { + ic_cdk::api::management_canister::http_request::http_request_with_cycles_with( + arg, + transform_func, + ) + .await + } + + #[cfg(not(target_arch = "wasm32"))] + { + // Mocking cycles is not implemented at the moment. + let _unused = cycles; + mock_http_request_with(arg, transform_func).await + } +} + +async fn mock_http_request_with( + arg: CanisterHttpRequestArgument, + transform_func: impl FnOnce(HttpResponse) -> HttpResponse + 'static, +) -> Result<(HttpResponse,), (RejectionCode, String)> { + assert!( + arg.transform.is_none(), + "`CanisterHttpRequestArgument`'s `transform` field must be `None` when using a closure" + ); + + mock_http_request(arg, transform_func).await +} + /// Handles incoming HTTP requests by retrieving a mock response based /// on the request, possibly delaying the response, transforming the response if necessary, /// and returning it. If there is no mock found, it returns an error. async fn mock_http_request( - request: CanisterHttpRequestArgument, + arg: CanisterHttpRequestArgument, + transform_func: impl FnOnce(HttpResponse) -> HttpResponse + 'static, ) -> Result<(HttpResponse,), (RejectionCode, String)> { - let mut mock = storage::mock_get(&request) + let mut mock = storage::mock_get(&arg) .ok_or((RejectionCode::CanisterReject, "No mock found".to_string()))?; mock.times_called += 1; storage::mock_insert(mock.clone()); @@ -120,7 +174,7 @@ async fn mock_http_request( }; // Check if the response body exceeds the maximum allowed size. - if let Some(max_response_bytes) = mock.request.max_response_bytes { + if let Some(max_response_bytes) = mock.arg.max_response_bytes { if mock_response.body.len() as u64 > max_response_bytes { return Err(( RejectionCode::SysFatal, @@ -134,27 +188,28 @@ async fn mock_http_request( } // Apply the transform function if one is specified. - let context = mock.request.clone().transform.map_or(vec![], |f| f.context); - let transformed_response = call_transform_function( - mock.request, - TransformArgs { - response: mock_response.clone(), - context, - }, - ) - .unwrap_or(mock_response); + let transformed_response = match arg.transform.clone() { + None => transform_func(mock_response), + Some(transform_context) => call_transform_function( + arg, + TransformArgs { + response: mock_response.clone(), + context: transform_context.context, + }, + ) + .unwrap_or(mock_response), + }; Ok((transformed_response,)) } /// Calls the transform function if one is specified in the request. fn call_transform_function( - request: CanisterHttpRequestArgument, - arg: TransformArgs, + arg: CanisterHttpRequestArgument, + transform_args: TransformArgs, ) -> Option { - request - .transform - .and_then(|t| storage::transform_function_call(t.function.0.method, arg)) + arg.transform + .and_then(|t| storage::transform_function_call(t.function.0.method, transform_args)) } /// Create a hash from a `CanisterHttpRequestArgument`, which includes its URL, diff --git a/src/ic-cdk-http-kit/src/storage.rs b/src/ic-cdk-http-kit/src/storage.rs index 1a1102fa9..0d2ceeb11 100644 --- a/src/ic-cdk-http-kit/src/storage.rs +++ b/src/ic-cdk-http-kit/src/storage.rs @@ -14,7 +14,7 @@ thread_local! { /// Inserts the provided mock into a thread-local hashmap. pub(crate) fn mock_insert(mock: Mock) { MOCKS.with(|cell| { - cell.borrow_mut().insert(hash(&mock.request), mock); + cell.borrow_mut().insert(hash(&mock.arg), mock); }); } From 8da8016d56ffd73c12fbbaa23f7755e80c373745 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Thu, 11 May 2023 20:28:10 +0200 Subject: [PATCH 12/20] add doc comments --- src/ic-cdk-http-kit/src/mock.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/ic-cdk-http-kit/src/mock.rs b/src/ic-cdk-http-kit/src/mock.rs index 9c5391854..b50f4e159 100644 --- a/src/ic-cdk-http-kit/src/mock.rs +++ b/src/ic-cdk-http-kit/src/mock.rs @@ -72,6 +72,11 @@ pub async fn http_request(arg: CanisterHttpRequestArgument) -> CallResult<(HttpR } } +/// Make an HTTP request to a given URL and return the HTTP response, possibly after a transformation. +/// +/// This is a helper function that compiles differently depending on the target architecture. +/// For wasm32 (assuming a canister in prod), it calls the IC method `http_request`. +/// For other architectures, it calls a mock function. #[cfg(any(docsrs, feature = "transform-closure"))] #[cfg_attr(docsrs, doc(cfg(feature = "transform-closure")))] pub async fn http_request_with( @@ -111,6 +116,11 @@ pub async fn http_request_with_cycles( } } +/// Make an HTTP request to a given URL and return the HTTP response, possibly after a transformation. +/// +/// This is a helper function that compiles differently depending on the target architecture. +/// For wasm32 (assuming a canister in prod), it calls the IC method `http_request`. +/// For other architectures, it calls a mock function. #[cfg(any(docsrs, feature = "transform-closure"))] #[cfg_attr(docsrs, doc(cfg(feature = "transform-closure")))] pub async fn http_request_with_cycles_with( @@ -135,6 +145,11 @@ pub async fn http_request_with_cycles_with( } } +/// Handles incoming HTTP requests by retrieving a mock response based +/// on the request, possibly delaying the response, transforming the response,, +/// and returning it. If there is no mock found, it returns an error. +#[cfg(any(docsrs, feature = "transform-closure"))] +#[cfg_attr(docsrs, doc(cfg(feature = "transform-closure")))] async fn mock_http_request_with( arg: CanisterHttpRequestArgument, transform_func: impl FnOnce(HttpResponse) -> HttpResponse + 'static, From f3238b09a9b9c7eb71de61a2946c0e86343651b8 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 12 May 2023 08:22:58 +0200 Subject: [PATCH 13/20] add tests for transform-closure feature --- src/ic-cdk-http-kit/Cargo.toml | 3 +++ src/ic-cdk-http-kit/run_all_tests.sh | 2 +- src/ic-cdk-http-kit/tests/api.rs | 34 ++++++++++++++++++++++++---- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/ic-cdk-http-kit/Cargo.toml b/src/ic-cdk-http-kit/Cargo.toml index f73735267..66455768f 100644 --- a/src/ic-cdk-http-kit/Cargo.toml +++ b/src/ic-cdk-http-kit/Cargo.toml @@ -38,3 +38,6 @@ tokio = { version = "1.15.0", features = [ "full" ] } futures = "0.3.27" serde_json = "1.0.94" tokio-test = "0.4.2" + +[features] +transform-closure = [] diff --git a/src/ic-cdk-http-kit/run_all_tests.sh b/src/ic-cdk-http-kit/run_all_tests.sh index bb81e508c..7ce3f23df 100755 --- a/src/ic-cdk-http-kit/run_all_tests.sh +++ b/src/ic-cdk-http-kit/run_all_tests.sh @@ -9,7 +9,7 @@ echo "README.md is up-to-date." # Run cargo tests for the crate. echo "Running cargo tests for the crate..." -cargo test +cargo test --features transform-closure echo "Cargo tests for the crate passed." # Run cargo tests for example projects. diff --git a/src/ic-cdk-http-kit/tests/api.rs b/src/ic-cdk-http-kit/tests/api.rs index fa2e40e3a..7af27d8e9 100644 --- a/src/ic-cdk-http-kit/tests/api.rs +++ b/src/ic-cdk-http-kit/tests/api.rs @@ -61,14 +61,14 @@ async fn test_http_request_called_several_times() { #[tokio::test] async fn test_http_request_transform_status() { // Arrange - fn transform(_arg: TransformArgs) -> HttpResponse { + fn transform_fn(_arg: TransformArgs) -> HttpResponse { ic_cdk_http_kit::create_response() .status(STATUS_CODE_NOT_FOUND) .build() } let request = ic_cdk_http_kit::create_request() .get("https://example.com") - .transform("transform", transform, vec![]) + .transform("transform_fn", transform_fn, vec![]) .build(); let mock_response = ic_cdk_http_kit::create_response() .status(STATUS_CODE_OK) @@ -86,19 +86,45 @@ async fn test_http_request_transform_status() { assert_eq!(ic_cdk_http_kit::times_called(request), 1); } +#[cfg(feature = "transform-closure")] +#[tokio::test] +async fn test_http_request_with_transform_closure_status() { + // Arrange + let request = ic_cdk_http_kit::create_request() + .get("https://example.com") + .build(); + let mock_response = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_OK) + .build(); + ic_cdk_http_kit::mock(request.clone(), Ok(mock_response)); + + // Act + let (response,) = ic_cdk_http_kit::http_request_with(request.clone(), move |mut response| { + // Modify the response status. + response.status = STATUS_CODE_NOT_FOUND.into(); + response + }) + .await + .unwrap(); + + // Assert + assert_eq!(response.status, STATUS_CODE_NOT_FOUND); + assert_eq!(ic_cdk_http_kit::times_called(request), 1); +} + #[tokio::test] async fn test_http_request_transform_body() { // Arrange const ORIGINAL_BODY: &str = "original body"; const TRANSFORMED_BODY: &str = "transformed body"; - fn transform(_arg: TransformArgs) -> HttpResponse { + fn transform_fn(_arg: TransformArgs) -> HttpResponse { ic_cdk_http_kit::create_response() .body_str(TRANSFORMED_BODY) .build() } let request = ic_cdk_http_kit::create_request() .get("https://dummyjson.com/todos/1") - .transform("transform", transform, vec![]) + .transform("transform_fn", transform_fn, vec![]) .build(); let mock_response = ic_cdk_http_kit::create_response() .status(STATUS_CODE_OK) From 0314cb6809ad1a17ce4dd66799ce67a969a8a9f0 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 12 May 2023 08:28:24 +0200 Subject: [PATCH 14/20] update readme --- src/ic-cdk-http-kit/README.md | 4 ++-- src/ic-cdk-http-kit/src/lib.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ic-cdk-http-kit/README.md b/src/ic-cdk-http-kit/README.md index 5ab828ad1..38b90b526 100644 --- a/src/ic-cdk-http-kit/README.md +++ b/src/ic-cdk-http-kit/README.md @@ -20,7 +20,7 @@ Note: To properly simulate the transformation function inside `ic_cdk_http_kit:: #### Creating a Request ```rust -fn transform_function(arg: TransformArgs) -> HttpResponse { +fn transform_fn(arg: TransformArgs) -> HttpResponse { // Modify arg.response here arg.response } @@ -28,7 +28,7 @@ fn transform_function(arg: TransformArgs) -> HttpResponse { let request = ic_cdk_http_kit::create_request() .get("https://dummyjson.com/todos/1") .max_response_bytes(1_024) - .transform("transform_function", transform_function, vec![]) + .transform("transform_fn", transform_fn, vec![]) .build(); ``` diff --git a/src/ic-cdk-http-kit/src/lib.rs b/src/ic-cdk-http-kit/src/lib.rs index 7a2a3280c..a87990856 100644 --- a/src/ic-cdk-http-kit/src/lib.rs +++ b/src/ic-cdk-http-kit/src/lib.rs @@ -19,7 +19,7 @@ //! //! ```rust //! # use ic_cdk::api::management_canister::http_request::{TransformArgs, HttpResponse}; -//! fn transform_function(arg: TransformArgs) -> HttpResponse { +//! fn transform_fn(arg: TransformArgs) -> HttpResponse { //! // Modify arg.response here //! arg.response //! } @@ -27,7 +27,7 @@ //! let request = ic_cdk_http_kit::create_request() //! .get("https://dummyjson.com/todos/1") //! .max_response_bytes(1_024) -//! .transform("transform_function", transform_function, vec![]) +//! .transform("transform_fn", transform_fn, vec![]) //! .build(); //! ``` //! From 7d96ff70372f64eb334eebcf704350bc504b8013 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Tue, 16 May 2023 13:41:43 +0200 Subject: [PATCH 15/20] add ic-cdk-http-kit.yml --- .github/workflows/ic-cdk-http-kit.yml | 56 +++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/ic-cdk-http-kit.yml diff --git a/.github/workflows/ic-cdk-http-kit.yml b/.github/workflows/ic-cdk-http-kit.yml new file mode 100644 index 000000000..250e03d22 --- /dev/null +++ b/.github/workflows/ic-cdk-http-kit.yml @@ -0,0 +1,56 @@ +name: ic-cdk-http-kit + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + rust-version: 1.66.1 + dfx-version: 0.13.1 + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Cache + uses: actions/cache@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + examples/${{ matrix.project-name }}/target/ + key: ${{ runner.os }}-${{ matrix.project-name }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.project-name }}- + ${{ runner.os }}- + + - name: Install Rust + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ env.rust-version }} + target: wasm32-unknown-unknown + components: rustfmt + + - name: Install DFX + run: | + export DFX_VERSION=${{env.dfx-version }} + echo Install DFX v$DFX_VERSION + yes | sh -ci "$(curl -fsSL https://internetcomputer.org/install.sh)" + + - name: Check README.md is in sync with crate doc + run: | + ./scripts/test_readme.sh From c96ac06225aec10d46fcc2841da6a33ec47a40c5 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Tue, 16 May 2023 13:48:11 +0200 Subject: [PATCH 16/20] add more ci tests --- .github/workflows/ic-cdk-http-kit.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/ic-cdk-http-kit.yml b/.github/workflows/ic-cdk-http-kit.yml index 250e03d22..42c266cc3 100644 --- a/.github/workflows/ic-cdk-http-kit.yml +++ b/.github/workflows/ic-cdk-http-kit.yml @@ -53,4 +53,25 @@ jobs: - name: Check README.md is in sync with crate doc run: | + CWD=$(pwd) + cd ./src/ic-cdk-http-kit ./scripts/test_readme.sh + cd "$CWD" + + - name: Run crate Cargo tests + run: | + cargo test --features transform-closure + + - name: Run examples Cargo tests + run: | + CWD=$(pwd) + cd ./src/ic-cdk-http-kit/examples + cargo test + cd "$CWD" + + - name: Run end-to-end examples/fetch_json tests + run: | + CWD=$(pwd) + cd ./src/ic-cdk-http-kit/examples/fetch_json + ./e2e-tests/fetch_quote.sh + cd "$CWD" From a8055e90f7829810d43c8ca10bf27f6b2e28449e Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Tue, 16 May 2023 13:50:52 +0200 Subject: [PATCH 17/20] install cargo-readme --- .github/workflows/ic-cdk-http-kit.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ic-cdk-http-kit.yml b/.github/workflows/ic-cdk-http-kit.yml index 42c266cc3..25c959ef3 100644 --- a/.github/workflows/ic-cdk-http-kit.yml +++ b/.github/workflows/ic-cdk-http-kit.yml @@ -55,6 +55,7 @@ jobs: run: | CWD=$(pwd) cd ./src/ic-cdk-http-kit + cargo install cargo-readme ./scripts/test_readme.sh cd "$CWD" From 68712480e2a5f9d3dd345ee71df0136b2b8c4596 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Tue, 16 May 2023 13:58:54 +0200 Subject: [PATCH 18/20] fix package cargo tests --- .github/workflows/ic-cdk-http-kit.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ic-cdk-http-kit.yml b/.github/workflows/ic-cdk-http-kit.yml index 25c959ef3..2a88f0fac 100644 --- a/.github/workflows/ic-cdk-http-kit.yml +++ b/.github/workflows/ic-cdk-http-kit.yml @@ -45,6 +45,10 @@ jobs: target: wasm32-unknown-unknown components: rustfmt + - name: Download ic-test-state-machine + run: | + bash scripts/download_state_machine_binary.sh + - name: Install DFX run: | export DFX_VERSION=${{env.dfx-version }} @@ -61,7 +65,7 @@ jobs: - name: Run crate Cargo tests run: | - cargo test --features transform-closure + cargo test --features transform-closure --package ic-cdk-http-kit - name: Run examples Cargo tests run: | From a15ab5c160d2d0cd51888b0b5457c234f2472751 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Tue, 16 May 2023 14:17:36 +0200 Subject: [PATCH 19/20] add working-directory --- .github/workflows/ic-cdk-http-kit.yml | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ic-cdk-http-kit.yml b/.github/workflows/ic-cdk-http-kit.yml index 2a88f0fac..703cba585 100644 --- a/.github/workflows/ic-cdk-http-kit.yml +++ b/.github/workflows/ic-cdk-http-kit.yml @@ -56,27 +56,22 @@ jobs: yes | sh -ci "$(curl -fsSL https://internetcomputer.org/install.sh)" - name: Check README.md is in sync with crate doc + working-directory: ./src/ic-cdk-http-kit run: | - CWD=$(pwd) - cd ./src/ic-cdk-http-kit cargo install cargo-readme ./scripts/test_readme.sh - cd "$CWD" - name: Run crate Cargo tests + working-directory: ./src/ic-cdk-http-kit run: | - cargo test --features transform-closure --package ic-cdk-http-kit + cargo test --all-features - name: Run examples Cargo tests + working-directory: ./src/ic-cdk-http-kit/examples run: | - CWD=$(pwd) - cd ./src/ic-cdk-http-kit/examples cargo test - cd "$CWD" - name: Run end-to-end examples/fetch_json tests + working-directory: ./src/ic-cdk-http-kit/examples/fetch_json run: | - CWD=$(pwd) - cd ./src/ic-cdk-http-kit/examples/fetch_json ./e2e-tests/fetch_quote.sh - cd "$CWD" From 239b1c890db7eafbbfb8556b7ce28837a4dfd509 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Tue, 16 May 2023 14:21:22 +0200 Subject: [PATCH 20/20] install cargo-readme only if not installed --- .github/workflows/ic-cdk-http-kit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ic-cdk-http-kit.yml b/.github/workflows/ic-cdk-http-kit.yml index 703cba585..117d19078 100644 --- a/.github/workflows/ic-cdk-http-kit.yml +++ b/.github/workflows/ic-cdk-http-kit.yml @@ -58,7 +58,7 @@ jobs: - name: Check README.md is in sync with crate doc working-directory: ./src/ic-cdk-http-kit run: | - cargo install cargo-readme + command -v cargo-readme || cargo install cargo-readme ./scripts/test_readme.sh - name: Run crate Cargo tests