Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions canhttp/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use std::{
};
use thiserror::Error;
use tower::{BoxError, Service, ServiceBuilder};
use tower_layer::Layer;

/// Thin wrapper around [`ic_cdk::management_canister::http_request`] that implements the
/// [`tower::Service`] trait. Its functionality can be extended by composing so-called
Expand Down Expand Up @@ -235,3 +236,58 @@ impl HttpsOutcallError for BoxError {
false
}
}

/// A [`tower::Layer`] that wraps services in a [`CanisterReadyService`] middleware.
#[derive(Clone, Debug, Default)]
pub struct CanisterReadyLayer;

impl<S> Layer<S> for CanisterReadyLayer {
type Service = CanisterReadyService<S>;

fn layer(&self, inner: S) -> Self::Service {
Self::Service { inner }
}
}

/// A [`tower::Service`] that checks that the canister is running before calling the inner service.
///
/// This is useful to prevent the canister making new HTTPs outcalls when it is in the stopping state
/// (see [stop_canister](https://docs.internetcomputer.org/references/ic-interface-spec#ic-stop_canister))
/// and ensure that the canister will be promptly stopped.
pub struct CanisterReadyService<S> {
inner: S,
}

/// Error returned by the [`CanisterReadyService`].
#[derive(Error, Clone, Debug, Eq, PartialEq)]
pub enum CanisterReadyError {
/// Canister is not running and has the given status code.
#[error("Canister is not running and has status {0}")]
CanisterNotRunning(u32),
}

impl<S, Req> Service<Req> for CanisterReadyService<S>
where
S: Service<Req>,
CanisterReadyError: Into<S::Error>,
{
type Response = S::Response;
type Error = S::Error;
type Future = S::Future;

fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
use ic_cdk::api::CanisterStatusCode;

match ic_cdk::api::canister_status() {
CanisterStatusCode::Running => self.inner.poll_ready(cx),
status => Poll::Ready(Err(CanisterReadyError::CanisterNotRunning(u32::from(
status,
))
.into())),
}
}

fn call(&mut self, req: Req) -> Self::Future {
self.inner.call(req)
}
}
5 changes: 3 additions & 2 deletions canhttp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
#![forbid(missing_docs)]

pub use client::{
Client, HttpsOutcallError, IcError, IsReplicatedRequestExtension,
MaxResponseBytesRequestExtension, TransformContextRequestExtension,
CanisterReadyError, CanisterReadyLayer, CanisterReadyService, Client, HttpsOutcallError,
IcError, IsReplicatedRequestExtension, MaxResponseBytesRequestExtension,
TransformContextRequestExtension,
};
pub use convert::ConvertServiceBuilder;

Expand Down
46 changes: 34 additions & 12 deletions examples/http_canister/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,20 @@ use canhttp::{
cycles::{ChargeMyself, CyclesAccountingServiceBuilder},
http::HttpConversionLayer,
observability::ObservabilityLayer,
Client, MaxResponseBytesRequestExtension,
CanisterReadyLayer, Client, MaxResponseBytesRequestExtension,
};
use http::Request;
use ic_cdk::update;
use tower::{BoxError, Service, ServiceBuilder, ServiceExt};

/// Make an HTTP POST request.
#[update]
pub async fn make_http_post_request() -> String {
let request = http::Request::post(format!("{}/anything", httpbin_base_url()))
.max_response_bytes(1_000)
.header("X-Id", "42")
.body("Hello, World!".as_bytes().to_vec())
.unwrap();

let response = http_client()
.ready()
.await
.expect("Client should be ready")
.call(request)
.call(request())
.await
.expect("Request should succeed");

Expand All @@ -31,6 +26,25 @@ pub async fn make_http_post_request() -> String {
String::from_utf8_lossy(response.body()).to_string()
}

/// Make multiple HTTP POST requests in a loop,
/// ensuring via [`CanisterReadyLayer`] that the loop will stop if the canister is stopped.
#[update]
pub async fn infinite_loop_make_http_post_request() -> String {
let mut client = ServiceBuilder::new()
.layer(CanisterReadyLayer)
.service(http_client());

loop {
match client.ready().await {
Ok(ready) => {
let response = ready.call(request()).await.expect("Request should succeed");
assert_eq!(response.status(), http::StatusCode::OK);
}
Err(e) => return format!("Not ready: {}", e),
}
}
}

fn http_client(
) -> impl Service<http::Request<Vec<u8>>, Response = http::Response<Vec<u8>>, Error = BoxError> {
ServiceBuilder::new()
Expand All @@ -53,10 +67,18 @@ fn http_client(
.service(Client::new_with_box_error())
}

fn httpbin_base_url() -> String {
option_env!("HTTPBIN_URL")
.unwrap_or_else(|| "https://httpbin.org")
.to_string()
fn request() -> Request<Vec<u8>> {
fn httpbin_base_url() -> String {
option_env!("HTTPBIN_URL")
.unwrap_or_else(|| "https://httpbin.org")
.to_string()
}

http::Request::post(format!("{}/anything", httpbin_base_url()))
.max_response_bytes(1_000)
.header("X-Id", "42")
.body("Hello, World!".as_bytes().to_vec())
.unwrap()
}

fn main() {}
66 changes: 66 additions & 0 deletions examples/http_canister/tests/tests.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
use candid::{Decode, Encode, Principal};
use ic_management_canister_types::CanisterIdRecord;
use ic_management_canister_types::CanisterSettings;
use pocket_ic::common::rest::{
CanisterHttpReply, CanisterHttpResponse, MockCanisterHttpResponse, RawEffectivePrincipal,
};
use pocket_ic::PocketIc;
use test_fixtures::Setup;

#[tokio::test]
Expand All @@ -12,3 +19,62 @@ async fn should_make_http_post_request() {
assert!(http_request_result.contains("Hello, World!"));
assert!(http_request_result.contains("\"X-Id\": \"42\""));
}

#[test]
fn should_not_make_http_request_when_stopping() {
let env = PocketIc::new();
let canister_id = env.create_canister_with_settings(
None,
Some(CanisterSettings {
controllers: Some(vec![Setup::DEFAULT_CONTROLLER]),
..CanisterSettings::default()
}),
);
env.add_cycles(canister_id, u64::MAX as u128);
env.install_canister(
canister_id,
test_fixtures::canister_wasm("http_canister"),
Encode!().unwrap(),
Some(Setup::DEFAULT_CONTROLLER),
);

let http_request = env
.submit_call(
canister_id,
Principal::anonymous(),
"infinite_loop_make_http_post_request",
Encode!().unwrap(),
)
.unwrap();

while env.get_canister_http().is_empty() {
env.tick();
}
for request in env.get_canister_http() {
env.mock_canister_http_response(MockCanisterHttpResponse {
subnet_id: request.subnet_id,
request_id: request.request_id,
response: CanisterHttpResponse::CanisterHttpReply(CanisterHttpReply {
status: 200,
headers: vec![],
body: vec![],
}),
additional_responses: vec![],
})
}

let _stopping = env
.submit_call_with_effective_principal(
Principal::management_canister(),
RawEffectivePrincipal::CanisterId(canister_id.as_slice().to_vec()),
Setup::DEFAULT_CONTROLLER,
"stop_canister",
Encode!(&CanisterIdRecord { canister_id }).unwrap(),
)
.unwrap();

let result = Decode!(&env.await_call(http_request).unwrap(), String).unwrap();

assert!(result.contains("Canister is not running and has status 2")); //Stopping
assert_eq!(env.get_canister_http(), vec![]);
}
2 changes: 1 addition & 1 deletion test_fixtures/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ impl Canister<'_> {
}
}

fn canister_wasm(canister_binary_name: &str) -> Vec<u8> {
pub fn canister_wasm(canister_binary_name: &str) -> Vec<u8> {
ic_test_utilities_load_wasm::load_wasm(
PathBuf::from(var("CARGO_MANIFEST_DIR").unwrap()).join("."),
canister_binary_name,
Expand Down