diff --git a/examples/Cargo.lock b/examples/Cargo.lock index 84806a3c7e7f..d606fe98ac65 100644 --- a/examples/Cargo.lock +++ b/examples/Cargo.lock @@ -2867,6 +2867,21 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "how-to-perform-http-requests" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_matches", + "async-graphql", + "axum", + "futures", + "linera-sdk", + "serde", + "test-log", + "tokio", +] + [[package]] name = "http" version = "0.2.12" diff --git a/examples/Cargo.toml b/examples/Cargo.toml index b3a29950db84..1a5287c851a0 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -8,6 +8,7 @@ members = [ "ethereum-tracker", "fungible", "gen-nft", + "how-to/perform-http-requests", "hex-game", "llm", "matching-engine", @@ -20,8 +21,10 @@ members = [ [workspace.dependencies] alloy = { version = "0.9.2", default-features = false } +anyhow = "1.0.80" assert_matches = "1.5.0" async-graphql = { version = "=7.0.2", default-features = false } +axum = "0.7.4" base64 = "0.22.0" bcs = "0.1.3" candle-core = "0.4.1" diff --git a/examples/how-to/perform-http-requests/Cargo.toml b/examples/how-to/perform-http-requests/Cargo.toml new file mode 100644 index 000000000000..1e7c76b3fead --- /dev/null +++ b/examples/how-to/perform-http-requests/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "how-to-perform-http-requests" +version = "0.1.0" +authors = ["Linera "] +edition = "2021" + +[dependencies] +async-graphql.workspace = true +linera-sdk.workspace = true +serde.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +anyhow.workspace = true +axum.workspace = true +tokio.workspace = true + +[dev-dependencies] +assert_matches.workspace = true +linera-sdk = { workspace = true, features = ["test"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +futures.workspace = true +linera-sdk = { workspace = true, features = ["test", "wasmer"] } +test-log.workspace = true + +[[bin]] +name = "how_to_perform_http_requests_contract" +path = "src/contract.rs" + +[[bin]] +name = "how_to_perform_http_requests_service" +path = "src/service.rs" + +[[bin]] +name = "test_http_server" +path = "src/test_http_server.rs" diff --git a/examples/how-to/perform-http-requests/README.md b/examples/how-to/perform-http-requests/README.md new file mode 100644 index 000000000000..7f19e2f71a08 --- /dev/null +++ b/examples/how-to/perform-http-requests/README.md @@ -0,0 +1,140 @@ +# How to perform HTTP requests + +This example application demonstrates how to perform HTTP requests from the service and from the +contract, in a few different ways: + +- From the service while handling a mutation. +- From the contract directly. +- From the service when it is being used as an oracle by the contract. + +## HTTP requests from the service + +The service is executed either on the client when requested by the user or on validators when the +service is queried as an oracle by the contract. In this first usage scenario, the HTTP request is +executed only in the client. + +The HTTP response can then be used by the service to either prepare a query response to the caller +or to prepare operations to be executed by the contract in a block proposal. + +## HTTP requests from the contract + +The contract can perform HTTP requests as well, but the responses must always be the same. The +requests are executed on the client and on all the validators. That means that the client and each +validator perform the HTTP request independently. The responses must all match (or at least match +in a quorum of validators) for the block the be confirmed. + +If the response varies per request (as a simple example, due to the presence of a "Date" timestamp +header in the response), the block proposal may end up being rejected by the validators. If there's +a risk of that happening, the contract should instead call the service as an oracle, and let the +service perform the HTTP request and return only the deterministic parts of the response. + +## HTTP requests using the service as an oracle + +The contract may call the service as an oracle. That means that that contracts sends a query to the +service and waits for its response. The execution of the contract is metered by executed +instruction, while the service executing as an oracle is metered by a coarse-grained timer. That +allows the service to execute non-deterministically, and as long as it always returns a +deterministic response back to the contract, the validators will agree on its execution and reach +consensus. + +In this scenario, the contract requests the service to perform the HTTP request. The HTTP request +is also executed in each validator. + +## Recommendation + +It is recommended to minimize the number of HTTP requests performed in total, in order to reduce +costs. Whenever possible, it's best to perform the request in the client using the service, and +forward only the HTTP response to the contract. The contract should then verify that the response +can be trusted. + +If there's no way to verify an off-chain HTTP response in the contract, then the request should be +made in the contract. However, if there's a risk of receiving different HTTP responses among the +validators, the contract should use the service as oracle to perform the HTTP request and return to +the contract only the data that is deterministic. Using the service as an oracle is more expensive, +so it should be avoided if possible. + +## Usage + +### Setting Up + +Before getting started, make sure that the binary tools `linera*` corresponding to +your version of `linera-sdk` are in your PATH. + +For the test, a simple HTTP server will be executed in the background. + +```bash +HTTP_PORT=9090 +cd examples +cargo run --bin test_http_server -- "$HTTP_PORT" & +cd .. +``` + +From the root of Linera repository, the environment can be configured to provide a `linera_spawn` +helper function useful for scripting, as follows: + +```bash +export PATH="$PWD/target/debug:$PATH" +source /dev/stdin <<<"$(linera net helper 2>/dev/null)" +``` + +To start the local Linera network: + +```bash +linera_spawn linera net up --with-faucet --faucet-port 8081 + +# Remember the URL of the faucet. +FAUCET_URL=http://localhost:8081 +``` + +We then create a wallet and obtain a chain to use with the application. + +```bash +export LINERA_WALLET="$LINERA_TMP_DIR/wallet.json" +export LINERA_STORAGE="rocksdb:$LINERA_TMP_DIR/client.db" + +linera wallet init --faucet $FAUCET_URL + +INFO=($(linera wallet request-chain --faucet $FAUCET_URL)) +CHAIN="${INFO[0]}" +``` + +Now, compile the application WebAssembly binaries, publish and create an application instance. + +```bash +(cd examples/how-to/perform-http-requests && cargo build --release --target wasm32-unknown-unknown) + +APPLICATION_ID=$(linera publish-and-create \ + examples/target/wasm32-unknown-unknown/release/how_to_perform_http_requests_{contract,service}.wasm \ + --json-parameters "\"http://localhost:$HTTP_PORT\"") +``` + +The `APPLICATION_ID` is saved so that it can be used in the GraphQL URL later. But first the +service that handles the GraphQL requests must be started. + +```bash +PORT=8080 +linera service --port $PORT & +``` + +#### Using GraphiQL + +Type each of these in the GraphiQL interface and substitute the env variables with their actual +values that we've defined above. + +- Navigate to the URL you get by running `echo "http://localhost:8080/chains/$CHAIN/applications/$APPLICATION_ID"`. +- To query the service to perform an HTTP query locally: +```gql,uri=http://localhost:8080/chains/$CHAIN/applications/$APPLICATION_ID +query { performHttpRequest } +``` +- To make the service perform an HTTP query locally and use the response to propose a block: +```gql,uri=http://localhost:8080/chains/$CHAIN/applications/$APPLICATION_ID +mutation { performHttpRequest } +``` +- To make the contract perform an HTTP request: +```gql,uri=http://localhost:8080/chains/$CHAIN/applications/$APPLICATION_ID +mutation { performHttpRequestInContract } +``` +- To make the contract use the service as an oracle to perform an HTTP request: +```gql,uri=http://localhost:8080/chains/$CHAIN/applications/$APPLICATION_ID +mutation { performHttpRequestAsOracle } +``` diff --git a/examples/how-to/perform-http-requests/src/contract.rs b/examples/how-to/perform-http-requests/src/contract.rs new file mode 100644 index 000000000000..778a23f07aa4 --- /dev/null +++ b/examples/how-to/perform-http-requests/src/contract.rs @@ -0,0 +1,120 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg_attr(target_arch = "wasm32", no_main)] + +use how_to_perform_http_requests::{Abi, Operation}; +use linera_sdk::{http, linera_base_types::WithContractAbi, Contract as _, ContractRuntime}; + +pub struct Contract { + runtime: ContractRuntime, +} + +linera_sdk::contract!(Contract); + +impl WithContractAbi for Contract { + type Abi = Abi; +} + +impl linera_sdk::Contract for Contract { + type Message = (); + type InstantiationArgument = (); + type Parameters = String; + + async fn load(runtime: ContractRuntime) -> Self { + Contract { runtime } + } + + async fn instantiate(&mut self, (): Self::InstantiationArgument) { + // Check that the global parameters can be deserialized correctly. + self.runtime.application_parameters(); + } + + async fn execute_operation(&mut self, operation: Self::Operation) -> Self::Response { + match operation { + Operation::HandleHttpResponse(response_body) => { + self.handle_http_response(response_body) + } + Operation::PerformHttpRequest => self.perform_http_request(), + Operation::UseServiceAsOracle => self.use_service_as_oracle(), + } + } + + async fn execute_message(&mut self, (): Self::Message) { + panic!("This application doesn't support any cross-chain messages"); + } + + async fn store(self) {} +} + +impl Contract { + /// Handles an HTTP response, ensuring it is valid. + /// + /// Because the `response_body` can come from outside the contract in an + /// [`Operation::HandleHttpResponse`], it could be forged. Therefore, the contract should + /// assume that the `response_body` is untrusted, and should perform validation and + /// verification steps to ensure that the `response_body` is real and can be trusted. + /// + /// Usually this is done by verifying that the response is signed by the trusted HTTP server. + /// In this example, the verification is simulated by checking that the `response_body` is + /// exactly an expected value. + fn handle_http_response(&self, response_body: Vec) { + assert_eq!(response_body, b"Hello, world!"); + } + + /// Performs an HTTP request directly in the contract. + /// + /// This only works if the HTTP response (including any HTTP headers the response contains) is + /// the same in a quorum of validators. Otherwise, the contract should call the service as an + /// oracle to perform the HTTP request and the service should only return the data that will be + /// the same in a quorum of validators. + fn perform_http_request(&mut self) { + let url = self.runtime.application_parameters(); + let response = self.runtime.http_request(http::Request::get(url)); + + self.handle_http_response(response.body); + } + + /// Uses the service as an oracle to perform the HTTP request. + /// + /// The service can then receive a non-deterministic response and return to the contract a + /// deterministic response. + fn use_service_as_oracle(&mut self) { + let application_id = self.runtime.application_id(); + let request = async_graphql::Request::new("query { performHttpRequest }"); + + let graphql_response = self.runtime.query_service(application_id, request); + + let async_graphql::Value::Object(graphql_response_data) = graphql_response.data else { + panic!("Unexpected response from service: {graphql_response:#?}"); + }; + let async_graphql::Value::List(ref http_response_list) = + graphql_response_data["performHttpRequest"] + else { + panic!( + "Unexpected response for service's `performHttpRequest` query: {:#?}", + graphql_response_data + ); + }; + let http_response = http_response_list + .iter() + .map(|value| { + let async_graphql::Value::Number(number) = value else { + panic!("Unexpected type in HTTP request body's bytes: {value:#?}"); + }; + + number + .as_i64() + .and_then(|integer| u8::try_from(integer).ok()) + .unwrap_or_else(|| { + panic!("Unexpected value in HTTP request body's bytes: {number:#?}") + }) + }) + .collect(); + + self.handle_http_response(http_response); + } +} + +#[path = "unit_tests/contract.rs"] +mod unit_tests; diff --git a/examples/how-to/perform-http-requests/src/lib.rs b/examples/how-to/perform-http-requests/src/lib.rs new file mode 100644 index 000000000000..e8a4d99e444c --- /dev/null +++ b/examples/how-to/perform-http-requests/src/lib.rs @@ -0,0 +1,35 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! ABI of the Counter Example Application + +use async_graphql::{Request, Response}; +use linera_sdk::{ + abi::{ContractAbi, ServiceAbi}, + graphql::GraphQLMutationRoot, +}; +use serde::{Deserialize, Serialize}; + +/// The marker type that connects the types used to interface with the application. +pub struct Abi; + +impl ContractAbi for Abi { + type Operation = Operation; + type Response = (); +} + +impl ServiceAbi for Abi { + type Query = Request; + type QueryResponse = Response; +} + +/// Operations that the contract can handle. +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize, GraphQLMutationRoot)] +pub enum Operation { + /// Handles the HTTP response of a request made outside the contract. + HandleHttpResponse(Vec), + /// Performs an HTTP request inside the contract. + PerformHttpRequest, + /// Requests the service to perform the HTTP request as an oracle. + UseServiceAsOracle, +} diff --git a/examples/how-to/perform-http-requests/src/service.rs b/examples/how-to/perform-http-requests/src/service.rs new file mode 100644 index 000000000000..08b87a8f5c48 --- /dev/null +++ b/examples/how-to/perform-http-requests/src/service.rs @@ -0,0 +1,125 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg_attr(target_arch = "wasm32", no_main)] + +use std::sync::Arc; + +use async_graphql::{EmptySubscription, Request, Response, Schema}; +use how_to_perform_http_requests::{Abi, Operation}; +use linera_sdk::{ensure, http, linera_base_types::WithServiceAbi, Service as _, ServiceRuntime}; + +#[derive(Clone)] +pub struct Service { + runtime: Arc>, +} + +linera_sdk::service!(Service); + +impl WithServiceAbi for Service { + type Abi = Abi; +} + +impl linera_sdk::Service for Service { + type Parameters = String; + + async fn new(runtime: ServiceRuntime) -> Self { + Service { + runtime: Arc::new(runtime), + } + } + + async fn handle_query(&self, request: Request) -> Response { + let schema = Schema::build( + Query { + service: self.clone(), + }, + Mutation { + service: self.clone(), + }, + EmptySubscription, + ) + .finish(); + schema.execute(request).await + } +} + +/// The handler for service queries. +struct Query { + service: Service, +} + +#[async_graphql::Object] +impl Query { + /// Performs an HTTP query in the service, and returns the response body if the status + /// code is OK. + /// + /// Note that any headers in the response are discarded. + pub async fn perform_http_request(&self) -> async_graphql::Result> { + self.service.perform_http_request() + } +} + +impl Service { + /// Performs an HTTP query in the service, and returns the response body if the status + /// code is OK. + /// + /// Note that any headers in the response are discarded. + pub fn perform_http_request(&self) -> async_graphql::Result> { + let url = self.runtime.application_parameters(); + let response = self.runtime.http_request(http::Request::get(url)); + + ensure!( + response.status == 200, + async_graphql::Error::new(format!( + "HTTP request failed with status code {}", + response.status + )) + ); + + Ok(response.body) + } +} + +/// The handler for service mutations. +struct Mutation { + service: Service, +} + +#[async_graphql::Object] +impl Mutation { + /// Performs an HTTP query in the service, and sends the response to the contract by scheduling + /// an [`Operation::HandleHttpResponse`]. + pub async fn perform_http_request(&self) -> async_graphql::Result { + let response = self.service.perform_http_request()?; + + self.service + .runtime + .schedule_operation(&Operation::HandleHttpResponse(response)); + + Ok(true) + } + + /// Requests the contract to perform an HTTP request, by scheduling an + /// [`Operation::PerformHttpRequest`]. + pub async fn perform_http_request_in_contract(&self) -> async_graphql::Result { + self.service + .runtime + .schedule_operation(&Operation::PerformHttpRequest); + + Ok(true) + } + + /// Requests the contract to use this service as an oracle to perform an HTTP request, by + /// scheduling an [`Operation::PerformHttpRequest`]. + pub async fn perform_http_request_as_oracle(&self) -> async_graphql::Result { + self.service + .runtime + .schedule_operation(&Operation::UseServiceAsOracle); + + Ok(true) + } +} + +#[path = "unit_tests/service.rs"] +mod unit_tests; diff --git a/examples/how-to/perform-http-requests/src/test_http_server.rs b/examples/how-to/perform-http-requests/src/test_http_server.rs new file mode 100644 index 000000000000..b32314e9d55c --- /dev/null +++ b/examples/how-to/perform-http-requests/src/test_http_server.rs @@ -0,0 +1,33 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! A simple HTTP server binary to use in the README test. + +#![cfg_attr(target_arch = "wasm32", no_main)] +#![cfg(not(target_arch = "wasm32"))] + +use std::{env, future::IntoFuture as _, net::Ipv4Addr}; + +use anyhow::anyhow; +use axum::{routing::get, Router}; +use tokio::net::TcpListener; + +/// The HTTP response expected by the contract. +const HTTP_RESPONSE_BODY: &str = "Hello, world!"; + +/// Runs an HTTP server that simple responds with the request expected by the contract. +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let port = env::args() + .nth(1) + .ok_or_else(|| anyhow!("Missing listen port argument"))? + .parse()?; + + let listener = TcpListener::bind((Ipv4Addr::from([127, 0, 0, 1]), port)).await?; + + let router = Router::new().route("/", get(|| async { HTTP_RESPONSE_BODY })); + + axum::serve(listener, router).into_future().await?; + + Ok(()) +} diff --git a/examples/how-to/perform-http-requests/src/unit_tests/contract.rs b/examples/how-to/perform-http-requests/src/unit_tests/contract.rs new file mode 100644 index 000000000000..bc38a09346e5 --- /dev/null +++ b/examples/how-to/perform-http-requests/src/unit_tests/contract.rs @@ -0,0 +1,163 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(test)] + +//! Unit tests for the contract. + +use how_to_perform_http_requests::{Abi, Operation}; +use linera_sdk::{ + http, linera_base_types::ApplicationId, util::BlockingWait as _, Contract as _, ContractRuntime, +}; + +use super::Contract; + +/// Tests if the contract accepts a valid HTTP response obtained off-chain. +/// +/// The contract should not panic if it receives a HTTP response that it can trust. In +/// this example application, that just means an HTTP response exactly to one the contract +/// expects, but in most applications this would involve signing the response in the HTTP +/// server and checking the signature in the contract. +#[test] +fn accepts_valid_off_chain_response() { + let mut contract = create_contract(); + + contract + .execute_operation(Operation::HandleHttpResponse(b"Hello, world!".to_vec())) + .blocking_wait(); +} + +/// Tests if the contract rejects an invalid HTTP response obtained off-chain. +/// +/// The contract should panic if it receives a HTTP response that it can't trust. In +/// this example application, that just means an HTTP response different from one it +/// expects, but in most applications this would involve checking the signature of the +/// response to see if it was signed by a trusted party that created the response. +#[test] +#[should_panic(expected = "assertion `left == right` failed")] +fn rejects_invalid_off_chain_response() { + let mut contract = create_contract(); + + contract + .execute_operation(Operation::HandleHttpResponse(b"Fake response".to_vec())) + .blocking_wait(); +} + +/// Tests if the contract performs an HTTP request and accepts it if it receives a valid +/// response. +#[test] +fn accepts_response_obtained_by_contract() { + let url = "http://some.test.url".to_owned(); + let mut contract = create_contract(); + + contract + .runtime + .set_application_parameters(url.clone()) + .add_expected_http_request( + http::Request::get(url), + http::Response::ok(b"Hello, world!".to_vec()), + ); + + contract + .execute_operation(Operation::PerformHttpRequest) + .blocking_wait(); +} + +/// Tests if the contract performs an HTTP request and rejects it if it receives an +/// invalid response. +#[test] +#[should_panic(expected = "assertion `left == right` failed")] +fn rejects_invalid_response_obtained_by_contract() { + let url = "http://some.test.url".to_owned(); + let mut contract = create_contract(); + + contract + .runtime + .set_application_parameters(url.clone()) + .add_expected_http_request( + http::Request::get(url), + http::Response::ok(b"Untrusted response".to_vec()), + ); + + contract + .execute_operation(Operation::PerformHttpRequest) + .blocking_wait(); +} + +/// Tests if the contract uses the service as an oracle to perform an HTTP request and +/// accepts the response if it's valid. +#[test] +fn accepts_response_from_oracle() { + let application_id = ApplicationId::default().with_abi::(); + let url = "http://some.test.url".to_owned(); + let mut contract = create_contract(); + + let http_response_graphql_list = "Hello, world!" + .as_bytes() + .iter() + .map(|&byte| async_graphql::Value::Number(byte.into())) + .collect(); + + contract + .runtime + .set_application_id(application_id) + .set_application_parameters(url.clone()) + .add_expected_service_query( + application_id, + async_graphql::Request::new("query { performHttpRequest }"), + async_graphql::Response::new(async_graphql::Value::Object( + [( + async_graphql::Name::new("performHttpRequest"), + async_graphql::Value::List(http_response_graphql_list), + )] + .into(), + )), + ); + + contract + .execute_operation(Operation::UseServiceAsOracle) + .blocking_wait(); +} + +/// Tests if the contract uses the service as an oracle to perform an HTTP request and +/// rejects the response if it's invalid. +#[test] +#[should_panic(expected = "assertion `left == right` failed")] +fn rejects_invalid_response_from_oracle() { + let application_id = ApplicationId::default().with_abi::(); + let url = "http://some.test.url".to_owned(); + let mut contract = create_contract(); + + let http_response_graphql_list = "Invalid response" + .as_bytes() + .iter() + .map(|&byte| async_graphql::Value::Number(byte.into())) + .collect(); + + contract + .runtime + .set_application_id(application_id) + .set_application_parameters(url.clone()) + .add_expected_service_query( + application_id, + async_graphql::Request::new("query { performHttpRequest }"), + async_graphql::Response::new(async_graphql::Value::Object( + [( + async_graphql::Name::new("performHttpRequest"), + async_graphql::Value::List(http_response_graphql_list), + )] + .into(), + )), + ); + + contract + .execute_operation(Operation::UseServiceAsOracle) + .blocking_wait(); +} + +/// Creates a [`Contract`] instance for testing. +fn create_contract() -> Contract { + let runtime = ContractRuntime::new(); + + Contract { runtime } +} diff --git a/examples/how-to/perform-http-requests/src/unit_tests/service.rs b/examples/how-to/perform-http-requests/src/unit_tests/service.rs new file mode 100644 index 000000000000..570985b16f3a --- /dev/null +++ b/examples/how-to/perform-http-requests/src/unit_tests/service.rs @@ -0,0 +1,166 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(test)] + +//! Unit tests for the service. + +use std::sync::Arc; + +use assert_matches::assert_matches; +use how_to_perform_http_requests::Operation; +use linera_sdk::{http, util::BlockingWait, Service as _, ServiceRuntime}; + +use super::Service; + +/// A dummy URL to use in the tests. +const TEST_BASE_URL: &str = "http://some.test.url"; + +/// Tests if an HTTP request is performed by a service query. +#[test] +fn service_query_performs_http_request() { + let http_response = b"Hello, world!"; + + let mut service = create_service(); + let runtime = Arc::get_mut(&mut service.runtime).expect("Runtime should not be shared"); + + runtime.add_expected_http_request( + http::Request::get(TEST_BASE_URL), + http::Response::ok(http_response), + ); + + let request = async_graphql::Request::new("query { performHttpRequest }"); + + let response = service.handle_query(request).blocking_wait(); + + let response_bytes = extract_response_bytes(response); + + assert_eq!(response_bytes, http_response); +} + +/// Tests if a failed HTTP request performed by a service query leads to a GraphQL error. +#[test] +fn service_query_returns_http_request_error() { + let mut service = create_service(); + let runtime = Arc::get_mut(&mut service.runtime).expect("Runtime should not be shared"); + + runtime.add_expected_http_request( + http::Request::get(TEST_BASE_URL), + http::Response::unauthorized(), + ); + + let request = async_graphql::Request::new("query { performHttpRequest }"); + + let response = service.handle_query(request).blocking_wait(); + + let error = extract_error_string(response); + + assert_eq!(error, "HTTP request failed with status code 401"); +} + +/// Tests if the service sends the HTTP response to the contract. +#[test] +fn service_sends_http_response_to_contract() { + let http_response = b"Hello, contract!"; + + let mut service = create_service(); + let runtime = Arc::get_mut(&mut service.runtime).expect("Runtime should not be shared"); + + runtime.add_expected_http_request( + http::Request::get(TEST_BASE_URL), + http::Response::ok(http_response), + ); + + let request = async_graphql::Request::new("mutation { performHttpRequest }"); + + service.handle_query(request).blocking_wait(); + + let operations = service.runtime.scheduled_operations::(); + + assert_eq!( + operations, + vec![Operation::HandleHttpResponse(http_response.to_vec())] + ); +} + +/// Tests if the service requests the contract to perform an HTTP request. +#[test] +fn service_requests_contract_to_perform_http_request() { + let service = create_service(); + + let request = async_graphql::Request::new("mutation { performHttpRequestInContract }"); + + service.handle_query(request).blocking_wait(); + + let operations = service.runtime.scheduled_operations::(); + + assert_eq!(operations, vec![Operation::PerformHttpRequest]); +} + +/// Tests if the service requests the contract to use the service as an oracle to perform an HTTP +/// request. +#[test] +fn service_requests_contract_to_use_it_as_an_oracle() { + let service = create_service(); + + let request = async_graphql::Request::new("mutation { performHttpRequestAsOracle }"); + + service.handle_query(request).blocking_wait(); + + let operations = service.runtime.scheduled_operations::(); + + assert_eq!(operations, vec![Operation::UseServiceAsOracle]); +} + +/// Creates a [`Service`] instance for testing. +fn create_service() -> Service { + let runtime = ServiceRuntime::new().with_application_parameters(TEST_BASE_URL.to_owned()); + + Service { + runtime: Arc::new(runtime), + } +} + +/// Extracts the HTTP response bytes from an [`async_graphql::Response`]. +fn extract_response_bytes(response: async_graphql::Response) -> Vec { + assert!(response.errors.is_empty()); + + let async_graphql::Value::Object(response_data) = response.data else { + panic!("Unexpected response from service: {response:#?}"); + }; + let async_graphql::Value::List(ref response_list) = response_data["performHttpRequest"] else { + panic!("Unexpected response for `performHttpRequest` query: {response_data:#?}"); + }; + + response_list + .iter() + .map(|value| { + let async_graphql::Value::Number(ref number) = value else { + panic!("Unexpected value in response list: {value:#?}"); + }; + number + .as_i64() + .expect("Invalid integer in response list: {number:#?}") + .try_into() + .expect("Invalid byte in response list: {number:#?}") + }) + .collect() +} + +/// Extracts the GraphQL error message from an [`async_graphql::Response`]. +fn extract_error_string(response: async_graphql::Response) -> String { + assert_matches!(response.data, async_graphql::Value::Null); + + let mut errors = response.errors; + + assert_eq!( + errors.len(), + 1, + "Unexpected error list from service: {errors:#?}" + ); + + errors + .pop() + .expect("There should be exactly one error, as asserted above") + .message +} diff --git a/examples/how-to/perform-http-requests/tests/http_requests.rs b/examples/how-to/perform-http-requests/tests/http_requests.rs new file mode 100644 index 000000000000..7abb3637f242 --- /dev/null +++ b/examples/how-to/perform-http-requests/tests/http_requests.rs @@ -0,0 +1,244 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests that perform real HTTP requests to a local HTTP server. + +#![cfg(not(target_arch = "wasm32"))] + +use axum::{routing::get, Router}; +use how_to_perform_http_requests::Abi; +use linera_sdk::test::{HttpServer, QueryOutcome, TestValidator}; + +/// Tests if service query performs HTTP request to allowed host. +#[test_log::test(tokio::test)] +async fn service_query_performs_http_request() -> anyhow::Result<()> { + const HTTP_RESPONSE_BODY: &str = "Hello, world!"; + + let http_server = + HttpServer::start(Router::new().route("/", get(|| async { HTTP_RESPONSE_BODY }))).await?; + let port = http_server.port(); + let url = format!("http://localhost:{port}/"); + + let (validator, application_id, chain) = + TestValidator::with_current_application::(url, ()).await; + + validator + .change_resource_control_policy(|policy| { + policy + .http_request_allow_list + .insert("localhost".to_owned()); + }) + .await; + + let QueryOutcome { response, .. } = chain + .graphql_query(application_id, "query { performHttpRequest }") + .await; + + let Some(byte_list) = response["performHttpRequest"].as_array() else { + panic!("Expected a list of bytes representing the response body, got {response:#}"); + }; + + let bytes = byte_list + .iter() + .map(|value| { + value + .as_i64() + .ok_or(()) + .and_then(|integer| integer.try_into().map_err(|_| ())) + }) + .collect::, _>>() + .unwrap_or_else(|()| { + panic!("Expected a list of bytes representing the response body, got {byte_list:#?}") + }); + + assert_eq!(bytes, HTTP_RESPONSE_BODY.as_bytes()); + + Ok(()) +} + +/// Tests if service query can't perform HTTP requests to hosts that aren't allowed. +#[test_log::test(tokio::test)] +#[should_panic(expected = "UnauthorizedHttpRequest")] +async fn service_query_cant_send_http_request_to_unauthorized_host() { + let url = "http://localhost/".to_owned(); + + let (_validator, application_id, chain) = + TestValidator::with_current_application::(url, ()).await; + + chain + .graphql_query(application_id, "query { performHttpRequest }") + .await; +} + +/// Tests if the service sends a valid HTTP response to the contract. +#[test_log::test(tokio::test)] +async fn service_sends_valid_http_response_to_contract() -> anyhow::Result<()> { + const HTTP_RESPONSE_BODY: &str = "Hello, world!"; + + let http_server = + HttpServer::start(Router::new().route("/", get(|| async { HTTP_RESPONSE_BODY }))).await?; + let port = http_server.port(); + let url = format!("http://localhost:{port}/"); + + let (validator, application_id, chain) = + TestValidator::with_current_application::(url, ()).await; + + validator + .change_resource_control_policy(|policy| { + policy + .http_request_allow_list + .insert("localhost".to_owned()); + }) + .await; + + chain + .graphql_mutation(application_id, "mutation { performHttpRequest }") + .await; + + Ok(()) +} + +/// Tests if the contract rejects an invalid HTTP response sent by the service. +#[test_log::test(tokio::test)] +#[should_panic(expected = "Failed to execute block")] +async fn contract_rejects_invalid_http_response_from_service() { + const HTTP_RESPONSE_BODY: &str = "Untrusted response"; + + let http_server = + HttpServer::start(Router::new().route("/", get(|| async { HTTP_RESPONSE_BODY }))) + .await + .expect("Failed to start test HTTP server"); + let port = http_server.port(); + let url = format!("http://localhost:{port}/"); + + let (validator, application_id, chain) = + TestValidator::with_current_application::(url, ()).await; + + validator + .change_resource_control_policy(|policy| { + policy + .http_request_allow_list + .insert("localhost".to_owned()); + }) + .await; + + chain + .graphql_mutation(application_id, "mutation { performHttpRequest }") + .await; +} + +/// Tests if the contract accepts a valid HTTP response it obtains by itself. +#[test_log::test(tokio::test)] +async fn contract_accepts_valid_http_response_it_obtains_by_itself() -> anyhow::Result<()> { + const HTTP_RESPONSE_BODY: &str = "Hello, world!"; + + let http_server = + HttpServer::start(Router::new().route("/", get(|| async { HTTP_RESPONSE_BODY }))).await?; + let port = http_server.port(); + let url = format!("http://localhost:{port}/"); + + let (validator, application_id, chain) = + TestValidator::with_current_application::(url, ()).await; + + validator + .change_resource_control_policy(|policy| { + policy + .http_request_allow_list + .insert("localhost".to_owned()); + }) + .await; + + chain + .graphql_mutation(application_id, "mutation { performHttpRequestInContract }") + .await; + + Ok(()) +} + +/// Tests if the contract rejects an invalid HTTP response it obtains by itself. +#[test_log::test(tokio::test)] +#[should_panic(expected = "Failed to execute block")] +async fn contract_rejects_invalid_http_response_it_obtains_by_itself() { + const HTTP_RESPONSE_BODY: &str = "Untrusted response"; + + let http_server = + HttpServer::start(Router::new().route("/", get(|| async { HTTP_RESPONSE_BODY }))) + .await + .expect("Failed to start test HTTP server"); + let port = http_server.port(); + let url = format!("http://localhost:{port}/"); + + let (validator, application_id, chain) = + TestValidator::with_current_application::(url, ()).await; + + validator + .change_resource_control_policy(|policy| { + policy + .http_request_allow_list + .insert("localhost".to_owned()); + }) + .await; + + chain + .graphql_mutation(application_id, "mutation { performHttpRequestInContract }") + .await; +} + +/// Tests if the contract accepts a valid HTTP response it obtains from the service acting as an +/// oracle. +#[test_log::test(tokio::test)] +async fn contract_accepts_valid_http_response_from_oracle() -> anyhow::Result<()> { + const HTTP_RESPONSE_BODY: &str = "Hello, world!"; + + let http_server = + HttpServer::start(Router::new().route("/", get(|| async { HTTP_RESPONSE_BODY }))).await?; + let port = http_server.port(); + let url = format!("http://localhost:{port}/"); + + let (validator, application_id, chain) = + TestValidator::with_current_application::(url, ()).await; + + validator + .change_resource_control_policy(|policy| { + policy + .http_request_allow_list + .insert("localhost".to_owned()); + }) + .await; + + chain + .graphql_mutation(application_id, "mutation { performHttpRequestAsOracle }") + .await; + + Ok(()) +} + +/// Tests if the contract rejects an invalid HTTP response it obtains from the service acting as an +/// oracle. +#[test_log::test(tokio::test)] +#[should_panic(expected = "Failed to execute block")] +async fn contract_rejects_invalid_http_response_from_oracle() { + const HTTP_RESPONSE_BODY: &str = "Invalid response"; + + let http_server = + HttpServer::start(Router::new().route("/", get(|| async { HTTP_RESPONSE_BODY }))) + .await + .expect("Failed to start test HTTP server"); + let port = http_server.port(); + let url = format!("http://localhost:{port}/"); + + let (validator, application_id, chain) = + TestValidator::with_current_application::(url, ()).await; + + validator + .change_resource_control_policy(|policy| { + policy + .http_request_allow_list + .insert("localhost".to_owned()); + }) + .await; + + chain + .graphql_mutation(application_id, "mutation { performHttpRequestAsOracle }") + .await; +} diff --git a/examples/how-to/perform-http-requests/tests/http_server/mod.rs b/examples/how-to/perform-http-requests/tests/http_server/mod.rs new file mode 100644 index 000000000000..b3bc1ff76f5f --- /dev/null +++ b/examples/how-to/perform-http-requests/tests/http_server/mod.rs @@ -0,0 +1,47 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! A simple HTTP server to use for testing. + +use std::{future::IntoFuture, net::Ipv4Addr}; + +use axum::Router; +use futures::FutureExt as _; +use tokio::{net::TcpListener, sync::oneshot}; + +/// A handle to a running HTTP server. +/// +/// The server is gracefully shutdown when this handle is dropped. +pub struct HttpServer { + port: u16, + _shutdown_sender: oneshot::Sender<()>, +} + +impl HttpServer { + /// Spawns a task with an HTTP server serving the routes defined by the [`Router`]. + /// + /// Returns a [`HttpServer`] handle to keep the server running in the background. + pub async fn start(router: Router) -> anyhow::Result { + let (shutdown_sender, shutdown_receiver) = oneshot::channel(); + let shutdown_signal = shutdown_receiver.map(|_| ()); + + let listener = TcpListener::bind((Ipv4Addr::from([127, 0, 0, 1]), 0)).await?; + let port = listener.local_addr()?.port(); + + tokio::spawn( + axum::serve(listener, router) + .with_graceful_shutdown(shutdown_signal) + .into_future(), + ); + + Ok(HttpServer { + port, + _shutdown_sender: shutdown_sender, + }) + } + + /// Returns the port this HTTP server is listening on. + pub fn port(&self) -> u16 { + self.port + } +} diff --git a/linera-base/src/crypto/secp256k1.rs b/linera-base/src/crypto/secp256k1.rs index 9452c35c5817..729065eaf00d 100644 --- a/linera-base/src/crypto/secp256k1.rs +++ b/linera-base/src/crypto/secp256k1.rs @@ -63,7 +63,7 @@ pub struct Secp256k1Signature(pub Signature); impl Secp256k1PublicKey { /// A fake public key used for testing. - #[cfg(with_testing)] + #[cfg(all(with_testing, not(target_arch = "wasm32")))] pub fn test_key(seed: u8) -> Self { use rand::SeedableRng; let mut rng = rand::rngs::StdRng::seed_from_u64(seed as u64); diff --git a/linera-sdk/src/test/mod.rs b/linera-sdk/src/test/mod.rs index 93d00febd835..fd3b1f451bf6 100644 --- a/linera-sdk/src/test/mod.rs +++ b/linera-sdk/src/test/mod.rs @@ -20,7 +20,10 @@ mod validator; #[cfg(with_integration_testing)] pub use { - linera_chain::data_types::{Medium, MessageAction}, + linera_chain::{ + data_types::{Medium, MessageAction}, + test::HttpServer, + }, linera_execution::{system::Recipient, QueryOutcome}, }; diff --git a/linera-service/tests/readme_test.rs b/linera-service/tests/readme_test.rs index 584a24c65784..9fb321966f51 100644 --- a/linera-service/tests/readme_test.rs +++ b/linera-service/tests/readme_test.rs @@ -25,6 +25,7 @@ use tokio::process::Command; #[test_case::test_case("../examples/crowd-funding" ; "crowd funding")] #[test_case::test_case("../examples/fungible" ; "fungible")] #[test_case::test_case("../examples/gen-nft" ; "gen-nft")] +#[test_case::test_case("../examples/how-to/perform-http-requests" ; "how-to-perform-http-requests")] #[test_case::test_case("../examples/hex-game" ; "hex-game")] #[test_case::test_case("../examples/lmm" ; "lmm")] #[test_case::test_case("../examples/native-fungible" ; "native-fungible")]