Skip to content

Commit 0a17a70

Browse files
authored
Add a "How-To" example for performing HTTP requests (#3509)
## Motivation Performing HTTP requests from applications is an important feature for Linera. There are a few different ways for applications to perform them, and there are tradeoffs between these ways. Therefore, this feature should be documented and a "How-To" example demonstrating how to use and test the feature would be useful. ## Proposal Add an example that performs HTTP requests in all different ways, and test them. ## Test Plan Unit and integration tests were added to test not just the application, but also to serve as testing examples and to also test the HTTP allow-list. ## Release Plan - Nothing to do, because this is just a new example being added to the repository. ## Links - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist)
1 parent 5800c0c commit 0a17a70

File tree

15 files changed

+1133
-2
lines changed

15 files changed

+1133
-2
lines changed

examples/Cargo.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ members = [
88
"ethereum-tracker",
99
"fungible",
1010
"gen-nft",
11+
"how-to/perform-http-requests",
1112
"hex-game",
1213
"llm",
1314
"matching-engine",
@@ -20,8 +21,10 @@ members = [
2021

2122
[workspace.dependencies]
2223
alloy = { version = "0.9.2", default-features = false }
24+
anyhow = "1.0.80"
2325
assert_matches = "1.5.0"
2426
async-graphql = { version = "=7.0.2", default-features = false }
27+
axum = "0.7.4"
2528
base64 = "0.22.0"
2629
bcs = "0.1.3"
2730
candle-core = "0.4.1"
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[package]
2+
name = "how-to-perform-http-requests"
3+
version = "0.1.0"
4+
authors = ["Linera <[email protected]>"]
5+
edition = "2021"
6+
7+
[dependencies]
8+
async-graphql.workspace = true
9+
linera-sdk.workspace = true
10+
serde.workspace = true
11+
12+
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
13+
anyhow.workspace = true
14+
axum.workspace = true
15+
tokio.workspace = true
16+
17+
[dev-dependencies]
18+
assert_matches.workspace = true
19+
linera-sdk = { workspace = true, features = ["test"] }
20+
21+
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
22+
futures.workspace = true
23+
linera-sdk = { workspace = true, features = ["test", "wasmer"] }
24+
test-log.workspace = true
25+
26+
[[bin]]
27+
name = "how_to_perform_http_requests_contract"
28+
path = "src/contract.rs"
29+
30+
[[bin]]
31+
name = "how_to_perform_http_requests_service"
32+
path = "src/service.rs"
33+
34+
[[bin]]
35+
name = "test_http_server"
36+
path = "src/test_http_server.rs"
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# How to perform HTTP requests
2+
3+
This example application demonstrates how to perform HTTP requests from the service and from the
4+
contract, in a few different ways:
5+
6+
- From the service while handling a mutation.
7+
- From the contract directly.
8+
- From the service when it is being used as an oracle by the contract.
9+
10+
## HTTP requests from the service
11+
12+
The service is executed either on the client when requested by the user or on validators when the
13+
service is queried as an oracle by the contract. In this first usage scenario, the HTTP request is
14+
executed only in the client.
15+
16+
The HTTP response can then be used by the service to either prepare a query response to the caller
17+
or to prepare operations to be executed by the contract in a block proposal.
18+
19+
## HTTP requests from the contract
20+
21+
The contract can perform HTTP requests as well, but the responses must always be the same. The
22+
requests are executed on the client and on all the validators. That means that the client and each
23+
validator perform the HTTP request independently. The responses must all match (or at least match
24+
in a quorum of validators) for the block the be confirmed.
25+
26+
If the response varies per request (as a simple example, due to the presence of a "Date" timestamp
27+
header in the response), the block proposal may end up being rejected by the validators. If there's
28+
a risk of that happening, the contract should instead call the service as an oracle, and let the
29+
service perform the HTTP request and return only the deterministic parts of the response.
30+
31+
## HTTP requests using the service as an oracle
32+
33+
The contract may call the service as an oracle. That means that that contracts sends a query to the
34+
service and waits for its response. The execution of the contract is metered by executed
35+
instruction, while the service executing as an oracle is metered by a coarse-grained timer. That
36+
allows the service to execute non-deterministically, and as long as it always returns a
37+
deterministic response back to the contract, the validators will agree on its execution and reach
38+
consensus.
39+
40+
In this scenario, the contract requests the service to perform the HTTP request. The HTTP request
41+
is also executed in each validator.
42+
43+
## Recommendation
44+
45+
It is recommended to minimize the number of HTTP requests performed in total, in order to reduce
46+
costs. Whenever possible, it's best to perform the request in the client using the service, and
47+
forward only the HTTP response to the contract. The contract should then verify that the response
48+
can be trusted.
49+
50+
If there's no way to verify an off-chain HTTP response in the contract, then the request should be
51+
made in the contract. However, if there's a risk of receiving different HTTP responses among the
52+
validators, the contract should use the service as oracle to perform the HTTP request and return to
53+
the contract only the data that is deterministic. Using the service as an oracle is more expensive,
54+
so it should be avoided if possible.
55+
56+
## Usage
57+
58+
### Setting Up
59+
60+
Before getting started, make sure that the binary tools `linera*` corresponding to
61+
your version of `linera-sdk` are in your PATH.
62+
63+
For the test, a simple HTTP server will be executed in the background.
64+
65+
```bash
66+
HTTP_PORT=9090
67+
cd examples
68+
cargo run --bin test_http_server -- "$HTTP_PORT" &
69+
cd ..
70+
```
71+
72+
From the root of Linera repository, the environment can be configured to provide a `linera_spawn`
73+
helper function useful for scripting, as follows:
74+
75+
```bash
76+
export PATH="$PWD/target/debug:$PATH"
77+
source /dev/stdin <<<"$(linera net helper 2>/dev/null)"
78+
```
79+
80+
To start the local Linera network:
81+
82+
```bash
83+
linera_spawn linera net up --with-faucet --faucet-port 8081
84+
85+
# Remember the URL of the faucet.
86+
FAUCET_URL=http://localhost:8081
87+
```
88+
89+
We then create a wallet and obtain a chain to use with the application.
90+
91+
```bash
92+
export LINERA_WALLET="$LINERA_TMP_DIR/wallet.json"
93+
export LINERA_STORAGE="rocksdb:$LINERA_TMP_DIR/client.db"
94+
95+
linera wallet init --faucet $FAUCET_URL
96+
97+
INFO=($(linera wallet request-chain --faucet $FAUCET_URL))
98+
CHAIN="${INFO[0]}"
99+
```
100+
101+
Now, compile the application WebAssembly binaries, publish and create an application instance.
102+
103+
```bash
104+
(cd examples/how-to/perform-http-requests && cargo build --release --target wasm32-unknown-unknown)
105+
106+
APPLICATION_ID=$(linera publish-and-create \
107+
examples/target/wasm32-unknown-unknown/release/how_to_perform_http_requests_{contract,service}.wasm \
108+
--json-parameters "\"http://localhost:$HTTP_PORT\"")
109+
```
110+
111+
The `APPLICATION_ID` is saved so that it can be used in the GraphQL URL later. But first the
112+
service that handles the GraphQL requests must be started.
113+
114+
```bash
115+
PORT=8080
116+
linera service --port $PORT &
117+
```
118+
119+
#### Using GraphiQL
120+
121+
Type each of these in the GraphiQL interface and substitute the env variables with their actual
122+
values that we've defined above.
123+
124+
- Navigate to the URL you get by running `echo "http://localhost:8080/chains/$CHAIN/applications/$APPLICATION_ID"`.
125+
- To query the service to perform an HTTP query locally:
126+
```gql,uri=http://localhost:8080/chains/$CHAIN/applications/$APPLICATION_ID
127+
query { performHttpRequest }
128+
```
129+
- To make the service perform an HTTP query locally and use the response to propose a block:
130+
```gql,uri=http://localhost:8080/chains/$CHAIN/applications/$APPLICATION_ID
131+
mutation { performHttpRequest }
132+
```
133+
- To make the contract perform an HTTP request:
134+
```gql,uri=http://localhost:8080/chains/$CHAIN/applications/$APPLICATION_ID
135+
mutation { performHttpRequestInContract }
136+
```
137+
- To make the contract use the service as an oracle to perform an HTTP request:
138+
```gql,uri=http://localhost:8080/chains/$CHAIN/applications/$APPLICATION_ID
139+
mutation { performHttpRequestAsOracle }
140+
```
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) Zefchain Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#![cfg_attr(target_arch = "wasm32", no_main)]
5+
6+
use how_to_perform_http_requests::{Abi, Operation};
7+
use linera_sdk::{http, linera_base_types::WithContractAbi, Contract as _, ContractRuntime};
8+
9+
pub struct Contract {
10+
runtime: ContractRuntime<Self>,
11+
}
12+
13+
linera_sdk::contract!(Contract);
14+
15+
impl WithContractAbi for Contract {
16+
type Abi = Abi;
17+
}
18+
19+
impl linera_sdk::Contract for Contract {
20+
type Message = ();
21+
type InstantiationArgument = ();
22+
type Parameters = String;
23+
24+
async fn load(runtime: ContractRuntime<Self>) -> Self {
25+
Contract { runtime }
26+
}
27+
28+
async fn instantiate(&mut self, (): Self::InstantiationArgument) {
29+
// Check that the global parameters can be deserialized correctly.
30+
self.runtime.application_parameters();
31+
}
32+
33+
async fn execute_operation(&mut self, operation: Self::Operation) -> Self::Response {
34+
match operation {
35+
Operation::HandleHttpResponse(response_body) => {
36+
self.handle_http_response(response_body)
37+
}
38+
Operation::PerformHttpRequest => self.perform_http_request(),
39+
Operation::UseServiceAsOracle => self.use_service_as_oracle(),
40+
}
41+
}
42+
43+
async fn execute_message(&mut self, (): Self::Message) {
44+
panic!("This application doesn't support any cross-chain messages");
45+
}
46+
47+
async fn store(self) {}
48+
}
49+
50+
impl Contract {
51+
/// Handles an HTTP response, ensuring it is valid.
52+
///
53+
/// Because the `response_body` can come from outside the contract in an
54+
/// [`Operation::HandleHttpResponse`], it could be forged. Therefore, the contract should
55+
/// assume that the `response_body` is untrusted, and should perform validation and
56+
/// verification steps to ensure that the `response_body` is real and can be trusted.
57+
///
58+
/// Usually this is done by verifying that the response is signed by the trusted HTTP server.
59+
/// In this example, the verification is simulated by checking that the `response_body` is
60+
/// exactly an expected value.
61+
fn handle_http_response(&self, response_body: Vec<u8>) {
62+
assert_eq!(response_body, b"Hello, world!");
63+
}
64+
65+
/// Performs an HTTP request directly in the contract.
66+
///
67+
/// This only works if the HTTP response (including any HTTP headers the response contains) is
68+
/// the same in a quorum of validators. Otherwise, the contract should call the service as an
69+
/// oracle to perform the HTTP request and the service should only return the data that will be
70+
/// the same in a quorum of validators.
71+
fn perform_http_request(&mut self) {
72+
let url = self.runtime.application_parameters();
73+
let response = self.runtime.http_request(http::Request::get(url));
74+
75+
self.handle_http_response(response.body);
76+
}
77+
78+
/// Uses the service as an oracle to perform the HTTP request.
79+
///
80+
/// The service can then receive a non-deterministic response and return to the contract a
81+
/// deterministic response.
82+
fn use_service_as_oracle(&mut self) {
83+
let application_id = self.runtime.application_id();
84+
let request = async_graphql::Request::new("query { performHttpRequest }");
85+
86+
let graphql_response = self.runtime.query_service(application_id, request);
87+
88+
let async_graphql::Value::Object(graphql_response_data) = graphql_response.data else {
89+
panic!("Unexpected response from service: {graphql_response:#?}");
90+
};
91+
let async_graphql::Value::List(ref http_response_list) =
92+
graphql_response_data["performHttpRequest"]
93+
else {
94+
panic!(
95+
"Unexpected response for service's `performHttpRequest` query: {:#?}",
96+
graphql_response_data
97+
);
98+
};
99+
let http_response = http_response_list
100+
.iter()
101+
.map(|value| {
102+
let async_graphql::Value::Number(number) = value else {
103+
panic!("Unexpected type in HTTP request body's bytes: {value:#?}");
104+
};
105+
106+
number
107+
.as_i64()
108+
.and_then(|integer| u8::try_from(integer).ok())
109+
.unwrap_or_else(|| {
110+
panic!("Unexpected value in HTTP request body's bytes: {number:#?}")
111+
})
112+
})
113+
.collect();
114+
115+
self.handle_http_response(http_response);
116+
}
117+
}
118+
119+
#[path = "unit_tests/contract.rs"]
120+
mod unit_tests;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) Zefchain Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! ABI of the Counter Example Application
5+
6+
use async_graphql::{Request, Response};
7+
use linera_sdk::{
8+
abi::{ContractAbi, ServiceAbi},
9+
graphql::GraphQLMutationRoot,
10+
};
11+
use serde::{Deserialize, Serialize};
12+
13+
/// The marker type that connects the types used to interface with the application.
14+
pub struct Abi;
15+
16+
impl ContractAbi for Abi {
17+
type Operation = Operation;
18+
type Response = ();
19+
}
20+
21+
impl ServiceAbi for Abi {
22+
type Query = Request;
23+
type QueryResponse = Response;
24+
}
25+
26+
/// Operations that the contract can handle.
27+
#[derive(Debug, Deserialize, Eq, PartialEq, Serialize, GraphQLMutationRoot)]
28+
pub enum Operation {
29+
/// Handles the HTTP response of a request made outside the contract.
30+
HandleHttpResponse(Vec<u8>),
31+
/// Performs an HTTP request inside the contract.
32+
PerformHttpRequest,
33+
/// Requests the service to perform the HTTP request as an oracle.
34+
UseServiceAsOracle,
35+
}

0 commit comments

Comments
 (0)