diff --git a/.github/workflows/ic-cdk-http-kit.yml b/.github/workflows/ic-cdk-http-kit.yml new file mode 100644 index 000000000..117d19078 --- /dev/null +++ b/.github/workflows/ic-cdk-http-kit.yml @@ -0,0 +1,77 @@ +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: Download ic-test-state-machine + run: | + bash scripts/download_state_machine_binary.sh + + - 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 + working-directory: ./src/ic-cdk-http-kit + run: | + command -v cargo-readme || cargo install cargo-readme + ./scripts/test_readme.sh + + - name: Run crate Cargo tests + working-directory: ./src/ic-cdk-http-kit + run: | + cargo test --all-features + + - name: Run examples Cargo tests + working-directory: ./src/ic-cdk-http-kit/examples + run: | + cargo test + + - name: Run end-to-end examples/fetch_json tests + working-directory: ./src/ic-cdk-http-kit/examples/fetch_json + run: | + ./e2e-tests/fetch_quote.sh 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/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..66455768f --- /dev/null +++ b/src/ic-cdk-http-kit/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "ic-cdk-http-kit" +version = "0.1.0" +authors = ["DFINITY Stiftung "] +edition = "2021" +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" +license = "Apache-2.0" +readme = "README.md" +categories = [ + "development-tools::testing", + "web-programming", +] +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 + +[dependencies] +candid = "0.8.2" +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" ] } + +[dev-dependencies] +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/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/README.md b/src/ic-cdk-http-kit/README.md new file mode 100644 index 000000000..38b90b526 --- /dev/null +++ b/src/ic-cdk-http-kit/README.md @@ -0,0 +1,82 @@ +# 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_fn(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_fn", transform_fn, vec![]) + .build(); +``` + +#### Creating a Response + +```rust +let mock_response = ic_cdk_http_kit::create_response() + .status(200) + .body_str("some text") + .build(); +``` + +#### Mocking + +```rust +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.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 + +```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. + +### 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/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..9c7914a1b --- /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 = { 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" + +[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..a66530e89 --- /dev/null +++ b/src/ic-cdk-http-kit/examples/fetch_json/src/main.rs @@ -0,0 +1,131 @@ +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", 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_str( + 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); + } +} 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..7ce3f23df --- /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 --features transform-closure +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." 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 new file mode 100644 index 000000000..a87990856 --- /dev/null +++ b/src/ic-cdk-http-kit/src/lib.rs @@ -0,0 +1,97 @@ +//! 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 +//! # use ic_cdk::api::management_canister::http_request::{TransformArgs, HttpResponse}; +//! fn transform_fn(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_fn", transform_fn, vec![]) +//! .build(); +//! ``` +//! +//! ### Creating a Response +//! +//! ```rust +//! let mock_response = ic_cdk_http_kit::create_response() +//! .status(200) +//! .body_str("some text") +//! .build(); +//! ``` +//! +//! ### Mocking +//! +//! ```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.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 +//! +//! ```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); +//! ``` +//! +//! ### More Examples +//! +//! Please refer to the provided usage examples in the [tests](./tests) or [examples](./examples) directories. +//! +//! ## 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..b50f4e159 --- /dev/null +++ b/src/ic-cdk-http-kit/src/mock.rs @@ -0,0 +1,255 @@ +//! 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) arg: CanisterHttpRequestArgument, + result: Option>, + delay: Duration, + times_called: u64, +} + +impl Mock { + /// Creates a new mock. + pub fn new( + arg: CanisterHttpRequestArgument, + result: Result, + delay: Duration, + ) -> Self { + Self { + arg, + result: Some(result), + delay, + times_called: 0, + } + } +} + +/// Mocks a HTTP request. +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( + arg: CanisterHttpRequestArgument, + result: Result, + delay: Duration, +) { + 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(arg: CanisterHttpRequestArgument) -> u64 { + storage::mock_get(&arg) + .map(|mock| mock.times_called) + .unwrap_or(0) +} + +/// 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, |response| response).await + } +} + +/// 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( + 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 + } +} + +/// 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, |response| response).await + } +} + +/// 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( + 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 + } +} + +/// 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, +) -> 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( + arg: CanisterHttpRequestArgument, + transform_func: impl FnOnce(HttpResponse) -> HttpResponse + 'static, +) -> Result<(HttpResponse,), (RejectionCode, String)> { + 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()); + + // 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.arg.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 = 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( + arg: CanisterHttpRequestArgument, + transform_args: TransformArgs, +) -> Option { + 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, +/// 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..e4437f801 --- /dev/null +++ b/src/ic-cdk-http-kit/src/request.rs @@ -0,0 +1,183 @@ +//! 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, candid_function_name: &str, func: T, context: Vec) -> Self + where + T: Fn(TransformArgs) -> HttpResponse + 'static, + { + self.0.transform = Some(create_transform_context( + candid_function_name.to_string(), + 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( + candid_function_name: String, + func: T, + context: Vec, +) -> TransformContext +where + T: Fn(TransformArgs) -> HttpResponse + 'static, +{ + #[cfg(target_arch = "wasm32")] + { + TransformContext::from_name(candid_function_name, 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(); + super::storage::transform_function_insert(candid_function_name.clone(), Box::new(func)); + + TransformContext { + function: TransformFunc(candid::Func { + principal, + method: candid_function_name, + }), + context, + } + } +} + +#[cfg(test)] +mod test { + use ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpResponse, TransformArgs, + }; + + /// 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", + 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); + } +} 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..03917e3ac --- /dev/null +++ b/src/ic-cdk-http-kit/src/response.rs @@ -0,0 +1,58 @@ +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: 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 + } + + /// 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..0d2ceeb11 --- /dev/null +++ b/src/ic-cdk-http-kit/src/storage.rs @@ -0,0 +1,43 @@ +use crate::mock::{hash, Mock}; +use ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpResponse, TransformArgs, +}; +use std::cell::RefCell; +use std::collections::HashMap; + +thread_local! { + static MOCKS: RefCell> = RefCell::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.borrow_mut().insert(hash(&mock.arg), 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.borrow().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.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.borrow().get(&name).map(|f| f(arg))) +} 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..7af27d8e9 --- /dev/null +++ b/src/ic-cdk-http-kit/tests/api.rs @@ -0,0 +1,447 @@ +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_str(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_str(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_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_fn", transform_fn, vec![]) + .build(); + let mock_response = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_OK) + .body_str("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); +} + +#[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_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_fn", transform_fn, vec![]) + .build(); + let mock_response = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_OK) + .body_str(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_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", + transform_context_to_body_text, + "some context".as_bytes().to_vec(), + ) + .build(); + let mock_response = ic_cdk_http_kit::create_response() + .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 + 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", transform_status, vec![]) + .build(); + let mock_response_1 = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_NOT_FOUND) + .body_str(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", transform_body, vec![]) + .build(); + let mock_response_2 = ic_cdk_http_kit::create_response() + .status(STATUS_CODE_OK) + .body_str(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!(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_str(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_str(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", + 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); +}