From 5bec0eba42ea0300d01b0b704193e955c1cb268d Mon Sep 17 00:00:00 2001 From: Janito Vaqueiro Ferreira Filho Date: Thu, 20 Feb 2025 14:02:32 -0300 Subject: [PATCH] Improve system api to perform HTTP requests (#2631) ## Motivation Linera applications can perform HTTP requests to external services, but the available `http_post` API was very limited, and prevented applications from configuring the request. This is often needed in order to add custom headers, for example for authentication. ## Proposal Replace the `http_post` API with a broader `http_request` API, which allows sending more configurable `http::Request`s, and returns a more detailed `http::Response`. ## Test Plan Tested with the demo for [Atoma](https://github.com/linera-io/atoma-demo). ## Release Plan - Backporting is not possible but we may want to deploy a new `devnet` and release a new SDK soon. - Contains breaking changes to the WIT interface and to the `linera-sdk` API. ## Links - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist) --------- Signed-off-by: Janito Vaqueiro Ferreira Filho Co-authored-by: Andreas Fackler --- Cargo.lock | 1 + examples/Cargo.lock | 1 + linera-base/Cargo.toml | 4 +- linera-base/build.rs | 1 + linera-base/src/data_types.rs | 10 +- linera-base/src/http.rs | 212 ++++++++++++++++++ linera-base/src/lib.rs | 1 + linera-execution/Cargo.toml | 2 +- linera-execution/src/execution_state_actor.rs | 38 ++-- linera-execution/src/lib.rs | 17 +- linera-execution/src/runtime.rs | 35 ++- linera-execution/src/wasm/system_api.rs | 25 +-- .../tests/snapshots/format__format.yaml.snap | 16 +- linera-sdk/Cargo.toml | 1 + .../src/contract/conversions_from_wit.rs | 17 ++ linera-sdk/src/contract/conversions_to_wit.rs | 41 ++++ linera-sdk/src/contract/runtime.rs | 7 +- linera-sdk/src/contract/test_runtime.rs | 24 +- linera-sdk/src/ethereum.rs | 35 ++- linera-sdk/src/lib.rs | 2 +- .../src/service/conversions_from_wit.rs | 21 ++ linera-sdk/src/service/conversions_to_wit.rs | 41 ++++ linera-sdk/src/service/runtime.rs | 12 + linera-sdk/src/service/test_runtime.rs | 29 ++- linera-sdk/wit/contract-system-api.wit | 32 ++- linera-sdk/wit/service-system-api.wit | 32 ++- 26 files changed, 550 insertions(+), 107 deletions(-) create mode 100644 linera-base/src/http.rs diff --git a/Cargo.lock b/Cargo.lock index 911e9cb15a47..f7535318beb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4402,6 +4402,7 @@ dependencies = [ "prometheus", "proptest", "rand", + "reqwest 0.11.27", "ruzstd", "secp256k1 0.30.0", "serde", diff --git a/examples/Cargo.lock b/examples/Cargo.lock index 03b753d1477d..e2754b3e7d45 100644 --- a/examples/Cargo.lock +++ b/examples/Cargo.lock @@ -3512,6 +3512,7 @@ dependencies = [ "prometheus", "proptest", "rand", + "reqwest 0.11.27", "ruzstd", "secp256k1", "serde", diff --git a/linera-base/Cargo.toml b/linera-base/Cargo.toml index 2bbb5fb8ba7d..79c8ea6fd301 100644 --- a/linera-base/Cargo.toml +++ b/linera-base/Cargo.toml @@ -12,8 +12,9 @@ repository.workspace = true version.workspace = true [features] -test = ["test-strategy", "proptest"] metrics = ["prometheus"] +reqwest = ["dep:reqwest"] +test = ["test-strategy", "proptest"] web = [ "getrandom/js", "rand/getrandom", @@ -47,6 +48,7 @@ linera-witty = { workspace = true, features = ["macros"] } prometheus = { workspace = true, optional = true } proptest = { workspace = true, optional = true, features = ["alloc"] } rand.workspace = true +reqwest = { workspace = true, optional = true } secp256k1.workspace = true serde.workspace = true serde-name.workspace = true diff --git a/linera-base/build.rs b/linera-base/build.rs index 1390e2fb4d6a..de990fb6208c 100644 --- a/linera-base/build.rs +++ b/linera-base/build.rs @@ -6,6 +6,7 @@ fn main() { web: { all(target_arch = "wasm32", feature = "web") }, chain: { all(target_arch = "wasm32", not(web)) }, with_metrics: { all(not(target_arch = "wasm32"), feature = "metrics") }, + with_reqwest: { feature = "reqwest" }, with_testing: { any(test, feature = "test") }, // the old version of `getrandom` we pin here is available on all targets, but diff --git a/linera-base/src/data_types.rs b/linera-base/src/data_types.rs index 3c9477d7d0cc..906e8c1a066a 100644 --- a/linera-base/src/data_types.rs +++ b/linera-base/src/data_types.rs @@ -30,7 +30,7 @@ use thiserror::Error; use crate::prometheus_util::{bucket_latencies, register_histogram_vec, MeasureLatency}; use crate::{ crypto::{BcsHashable, CryptoHash}, - doc_scalar, hex_debug, + doc_scalar, hex_debug, http, identifiers::{ ApplicationId, BlobId, BlobType, BytecodeId, ChainId, Destination, EventId, GenericApplicationId, MessageId, StreamId, UserApplicationId, @@ -770,12 +770,8 @@ pub enum OracleResponse { #[serde(with = "serde_bytes")] Vec, ), - /// The response from an HTTP POST request. - Post( - #[debug(with = "hex_debug")] - #[serde(with = "serde_bytes")] - Vec, - ), + /// The response from an HTTP request. + Http(http::Response), /// A successful read or write of a blob. Blob(BlobId), /// An assertion oracle that passed. diff --git a/linera-base/src/http.rs b/linera-base/src/http.rs new file mode 100644 index 000000000000..1dc85696711e --- /dev/null +++ b/linera-base/src/http.rs @@ -0,0 +1,212 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Types used when performing HTTP requests. + +use custom_debug_derive::Debug; +use linera_witty::{WitLoad, WitStore, WitType}; +use serde::{Deserialize, Serialize}; + +use crate::hex_debug; + +/// An HTTP request. +#[derive(Clone, Debug, Eq, PartialEq, WitLoad, WitStore, WitType)] +#[witty(name = "http-request")] +pub struct Request { + /// The [`Method`] used for the HTTP request. + pub method: Method, + + /// The URL this request is intended to. + pub url: String, + + /// The headers that should be included in the request. + pub headers: Vec
, + + /// The body of the request. + #[debug(with = "hex_debug")] + pub body: Vec, +} + +impl Request { + /// Creates an HTTP GET [`Request`] for a `url`. + pub fn get(url: impl Into) -> Self { + Request { + method: Method::Get, + url: url.into(), + headers: vec![], + body: vec![], + } + } + + /// Creates an HTTP POST [`Request`] for a `url` with a `payload` that's arbitrary bytes. + pub fn post(url: impl Into, payload: impl Into>) -> Self { + Request { + method: Method::Post, + url: url.into(), + headers: vec![], + body: payload.into(), + } + } + + /// Creates an HTTP POST [`Request`] for a `url` with a body that's the `payload` serialized to + /// JSON. + pub fn post_json( + url: impl Into, + payload: &impl Serialize, + ) -> Result { + Ok(Request { + method: Method::Post, + url: url.into(), + headers: vec![Header::new("Content-Type", b"application/json")], + body: serde_json::to_vec(payload)?, + }) + } + + /// Adds a header to this [`Request`]. + pub fn with_header(mut self, name: impl Into, value: impl Into>) -> Self { + self.headers.push(Header::new(name, value)); + self + } +} + +/// The method used in an HTTP request. +#[derive(Clone, Copy, Debug, Eq, PartialEq, WitLoad, WitStore, WitType)] +#[witty(name = "http-method")] +pub enum Method { + /// A GET request. + Get, + + /// A POST request. + Post, + + /// A PUT request. + Put, + + /// A DELETE request. + Delete, + + /// A HEAD request. + Head, + + /// A OPTIONS request. + Options, + + /// A CONNECT request. + Connect, + + /// A PATCH request. + Patch, + + /// A TRACE request. + Trace, +} + +#[cfg(with_reqwest)] +impl From for reqwest::Method { + fn from(method: Method) -> Self { + match method { + Method::Get => reqwest::Method::GET, + Method::Post => reqwest::Method::POST, + Method::Put => reqwest::Method::PUT, + Method::Delete => reqwest::Method::DELETE, + Method::Head => reqwest::Method::HEAD, + Method::Options => reqwest::Method::OPTIONS, + Method::Connect => reqwest::Method::CONNECT, + Method::Patch => reqwest::Method::PATCH, + Method::Trace => reqwest::Method::TRACE, + } + } +} + +/// A response for an HTTP request. +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize, WitLoad, WitStore, WitType)] +#[witty(name = "http-response")] +pub struct Response { + /// The status code of the HTTP response. + pub status: u16, + + /// The headers included in the response. + pub headers: Vec
, + + /// The body of the response. + #[debug(with = "hex_debug")] + #[serde(with = "serde_bytes")] + pub body: Vec, +} + +impl Response { + /// Creates an HTTP [`Response`] with a user defined `status_code`. + pub fn new(status_code: u16) -> Self { + Response { + status: status_code, + headers: vec![], + body: vec![], + } + } + + /// Creates an HTTP [`Response`] with an OK status code and the provided `body`. + pub fn ok(body: impl Into>) -> Self { + Response { + status: 200, + headers: vec![], + body: body.into(), + } + } + + /// Creates an HTTP [`Response`] with an Unauthorized status code. + pub fn unauthorized() -> Self { + Response { + status: 401, + headers: vec![], + body: vec![], + } + } + + /// Adds a header to this [`Response`]. + pub fn with_header(mut self, name: impl Into, value: impl Into>) -> Self { + self.headers.push(Header::new(name, value)); + self + } +} + +#[cfg(with_reqwest)] +impl Response { + /// Creates a [`Response`] from a [`reqwest::Response`], waiting for it to be fully + /// received. + pub async fn from_reqwest(response: reqwest::Response) -> reqwest::Result { + let headers = response + .headers() + .into_iter() + .map(|(name, value)| Header::new(name.to_string(), value.as_bytes())) + .collect(); + + Ok(Response { + status: response.status().as_u16(), + headers, + body: response.bytes().await?.to_vec(), + }) + } +} + +/// A header for an HTTP request or response. +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize, WitLoad, WitStore, WitType)] +#[witty(name = "http-header")] +pub struct Header { + /// The header name. + pub name: String, + + /// The value of the header. + #[debug(with = "hex_debug")] + #[serde(with = "serde_bytes")] + pub value: Vec, +} + +impl Header { + /// Creates a new [`Header`] with the provided `name` and `value`. + pub fn new(name: impl Into, value: impl Into>) -> Self { + Header { + name: name.into(), + value: value.into(), + } + } +} diff --git a/linera-base/src/lib.rs b/linera-base/src/lib.rs index faa1bfe8aa9b..fc4727feb03e 100644 --- a/linera-base/src/lib.rs +++ b/linera-base/src/lib.rs @@ -22,6 +22,7 @@ pub mod data_types; pub mod dyn_convert; mod graphql; pub mod hashed; +pub mod http; pub mod identifiers; mod limited_writer; pub mod ownership; diff --git a/linera-execution/Cargo.toml b/linera-execution/Cargo.toml index 72663c918979..d2d2304f84ee 100644 --- a/linera-execution/Cargo.toml +++ b/linera-execution/Cargo.toml @@ -56,7 +56,7 @@ dyn-clone.workspace = true futures.workspace = true hex = { workspace = true, optional = true } js-sys = { workspace = true, optional = true } -linera-base.workspace = true +linera-base = { workspace = true, features = ["reqwest"] } linera-views.workspace = true linera-views-derive.workspace = true linera-witty = { workspace = true, features = ["log", "macros"] } diff --git a/linera-execution/src/execution_state_actor.rs b/linera-execution/src/execution_state_actor.rs index f744bcaab283..dd511e6cc3f2 100644 --- a/linera-execution/src/execution_state_actor.rs +++ b/linera-execution/src/execution_state_actor.rs @@ -12,7 +12,7 @@ use futures::channel::mpsc; use linera_base::prometheus_util::{bucket_latencies, register_histogram_vec, MeasureLatency as _}; use linera_base::{ data_types::{Amount, ApplicationPermissions, BlobContent, Timestamp}, - hex_debug, hex_vec_debug, + hex_debug, hex_vec_debug, http, identifiers::{Account, AccountOwner, BlobId, MessageId, Owner}, ownership::ChainOwnership, }; @@ -20,7 +20,7 @@ use linera_views::{batch::Batch, context::Context, views::View}; use oneshot::Sender; #[cfg(with_metrics)] use prometheus::HistogramVec; -use reqwest::{header::CONTENT_TYPE, Client}; +use reqwest::{header::HeaderMap, Client}; use crate::{ system::{CreateApplicationResult, OpenChainConfig, Recipient}, @@ -329,21 +329,20 @@ where callback.respond(bytes); } - HttpPost { - url, - content_type, - payload, - callback, - } => { - let res = Client::new() - .post(url) - .body(payload) - .header(CONTENT_TYPE, content_type) + PerformHttpRequest { request, callback } => { + let headers = request + .headers + .into_iter() + .map(|http::Header { name, value }| Ok((name.parse()?, value.try_into()?))) + .collect::>()?; + + let response = Client::new() + .request(request.method.into(), request.url) + .body(request.body) + .headers(headers) .send() .await?; - let body = res.bytes().await?; - let bytes = body.as_ref().to_vec(); - callback.respond(bytes); + callback.respond(http::Response::from_reqwest(response).await?); } ReadBlobContent { blob_id, callback } => { @@ -524,13 +523,10 @@ pub enum ExecutionRequest { callback: Sender>, }, - HttpPost { - url: String, - content_type: String, - #[debug(with = hex_debug)] - payload: Vec, + PerformHttpRequest { + request: http::Request, #[debug(skip)] - callback: Sender>, + callback: Sender, }, ReadBlobContent { diff --git a/linera-execution/src/lib.rs b/linera-execution/src/lib.rs index 7b25f2819739..c688ab1e8988 100644 --- a/linera-execution/src/lib.rs +++ b/linera-execution/src/lib.rs @@ -41,7 +41,7 @@ use linera_base::{ Amount, ApplicationPermissions, ArithmeticError, Blob, BlockHeight, DecompressionError, Resources, SendMessageRequest, Timestamp, UserApplicationDescription, }, - doc_scalar, hex_debug, + doc_scalar, hex_debug, http, identifiers::{ Account, AccountOwner, ApplicationId, BlobId, BytecodeId, ChainId, ChannelName, Destination, GenericApplicationId, MessageId, Owner, StreamName, UserApplicationId, @@ -282,6 +282,11 @@ pub enum ExecutionError { ServiceModuleSend(#[from] linera_base::task::SendError), #[error("Blobs not found: {0:?}")] BlobsNotFound(Vec), + + #[error("Invalid HTTP header name used for HTTP request")] + InvalidHeaderName(#[from] reqwest::header::InvalidHeaderName), + #[error("Invalid HTTP header value used for HTTP request")] + InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue), } impl From for ExecutionError { @@ -613,13 +618,11 @@ pub trait BaseRuntime { promise: &Self::FindKeyValuesByPrefix, ) -> Result, Vec)>, ExecutionError>; - /// Makes a POST request to the given URL and returns the answer, if any. - fn http_post( + /// Makes an HTTP request to the given URL and returns the answer, if any. + fn perform_http_request( &mut self, - url: &str, - content_type: String, - payload: Vec, - ) -> Result, ExecutionError>; + request: http::Request, + ) -> Result; /// Ensures that the current time at block validation is `< timestamp`. Note that block /// validation happens at or after the block timestamp, but isn't necessarily the same. diff --git a/linera-execution/src/runtime.rs b/linera-execution/src/runtime.rs index b2129585c9e1..01e87c751d3f 100644 --- a/linera-execution/src/runtime.rs +++ b/linera-execution/src/runtime.rs @@ -15,7 +15,7 @@ use linera_base::{ Amount, ApplicationPermissions, ArithmeticError, BlockHeight, OracleResponse, Resources, SendMessageRequest, Timestamp, }, - ensure, + ensure, http, identifiers::{ Account, AccountOwner, ApplicationId, BlobId, BlobType, ChainId, ChannelFullName, ChannelName, MessageId, Owner, StreamId, StreamName, @@ -697,13 +697,11 @@ impl BaseRuntime for SyncRuntimeHandle { self.inner().find_key_values_by_prefix_wait(promise) } - fn http_post( + fn perform_http_request( &mut self, - url: &str, - content_type: String, - payload: Vec, - ) -> Result, ExecutionError> { - self.inner().http_post(url, content_type, payload) + request: http::Request, + ) -> Result { + self.inner().perform_http_request(request) } fn assert_before(&mut self, timestamp: Timestamp) -> Result<(), ExecutionError> { @@ -946,36 +944,31 @@ impl BaseRuntime for SyncRuntimeInternal { Ok(key_values) } - fn http_post( + fn perform_http_request( &mut self, - url: &str, - content_type: String, - payload: Vec, - ) -> Result, ExecutionError> { + request: http::Request, + ) -> Result { ensure!( cfg!(feature = "unstable-oracles"), ExecutionError::UnstableOracle ); - let bytes = + let response = if let Some(response) = self.transaction_tracker.next_replayed_oracle_response()? { match response { - OracleResponse::Post(bytes) => bytes, + OracleResponse::Http(response) => response, _ => return Err(ExecutionError::OracleResponseMismatch), } } else { - let url = url.to_string(); self.execution_state_sender - .send_request(|callback| ExecutionRequest::HttpPost { - url, - content_type, - payload, + .send_request(|callback| ExecutionRequest::PerformHttpRequest { + request, callback, })? .recv_response()? }; self.transaction_tracker - .add_oracle_response(OracleResponse::Post(bytes.clone())); - Ok(bytes) + .add_oracle_response(OracleResponse::Http(response.clone())); + Ok(response) } fn assert_before(&mut self, timestamp: Timestamp) -> Result<(), ExecutionError> { diff --git a/linera-execution/src/wasm/system_api.rs b/linera-execution/src/wasm/system_api.rs index 87bcb886855e..203d39ad4e2c 100644 --- a/linera-execution/src/wasm/system_api.rs +++ b/linera-execution/src/wasm/system_api.rs @@ -6,6 +6,7 @@ use std::{any::Any, collections::HashMap, marker::PhantomData}; use linera_base::{ crypto::CryptoHash, data_types::{Amount, ApplicationPermissions, BlockHeight, SendMessageRequest, Timestamp}, + http, identifiers::{ Account, AccountOwner, ApplicationId, ChainId, ChannelName, MessageId, Owner, StreamName, }, @@ -377,17 +378,15 @@ where .map_err(|error| RuntimeError::Custom(error.into())) } - /// Makes a POST request to the given URL and returns the response body. - fn http_post( + /// Makes an HTTP request to the given URL and returns the response body. + fn perform_http_request( caller: &mut Caller, - query: String, - content_type: String, - payload: Vec, - ) -> Result, RuntimeError> { + request: http::Request, + ) -> Result { caller .user_data_mut() .runtime - .http_post(&query, content_type, payload) + .perform_http_request(request) .map_err(|error| RuntimeError::Custom(error.into())) } @@ -590,17 +589,15 @@ where .map_err(|error| RuntimeError::Custom(error.into())) } - /// Makes a POST request to the given URL and returns the response body. - fn http_post( + /// Makes an HTTP request to the given URL and returns the response body. + fn perform_http_request( caller: &mut Caller, - query: String, - content_type: String, - payload: Vec, - ) -> Result, RuntimeError> { + request: http::Request, + ) -> Result { caller .user_data_mut() .runtime - .http_post(&query, content_type, payload) + .perform_http_request(request) .map_err(|error| RuntimeError::Custom(error.into())) } diff --git a/linera-rpc/tests/snapshots/format__format.yaml.snap b/linera-rpc/tests/snapshots/format__format.yaml.snap index 5dcb728d45ac..aa51e6677c4e 100644 --- a/linera-rpc/tests/snapshots/format__format.yaml.snap +++ b/linera-rpc/tests/snapshots/format__format.yaml.snap @@ -449,6 +449,10 @@ HandleValidatedCertificateRequest: STRUCT: - certificate: TYPENAME: ValidatedBlockCertificate +Header: + STRUCT: + - name: STR + - value: BYTES IncomingBundle: STRUCT: - origin: @@ -682,8 +686,9 @@ OracleResponse: Service: NEWTYPE: BYTES 1: - Post: - NEWTYPE: BYTES + Http: + NEWTYPE: + TYPENAME: Response 2: Blob: NEWTYPE: @@ -805,6 +810,13 @@ ResourceControlPolicy: - maximum_block_proposal_size: U64 - maximum_bytes_read_per_block: U64 - maximum_bytes_written_per_block: U64 +Response: + STRUCT: + - status: U16 + - headers: + SEQ: + TYPENAME: Header + - body: BYTES Round: ENUM: 0: diff --git a/linera-sdk/Cargo.toml b/linera-sdk/Cargo.toml index 9ff8eca75bad..ddb11566f509 100644 --- a/linera-sdk/Cargo.toml +++ b/linera-sdk/Cargo.toml @@ -20,6 +20,7 @@ targets = ["wasm32-unknown-unknown", "x86_64-unknown-linux-gnu"] [features] ethereum = ["async-trait", "linera-ethereum"] +unstable-oracles = ["linera-core/unstable-oracles", "linera-execution/unstable-oracles"] wasmer = [ "linera-core/wasmer", "linera-execution/wasmer", diff --git a/linera-sdk/src/contract/conversions_from_wit.rs b/linera-sdk/src/contract/conversions_from_wit.rs index fe22993ad2c3..86013f146440 100644 --- a/linera-sdk/src/contract/conversions_from_wit.rs +++ b/linera-sdk/src/contract/conversions_from_wit.rs @@ -6,6 +6,7 @@ use linera_base::{ crypto::CryptoHash, data_types::{Amount, BlockHeight, TimeDelta, Timestamp}, + http, identifiers::{ApplicationId, BytecodeId, ChainId, MessageId, Owner}, ownership::{ ChainOwnership, ChangeApplicationPermissionsError, CloseChainError, TimeoutConfig, @@ -147,3 +148,19 @@ impl From for ChangeApplicati } } } + +impl From for http::Response { + fn from(guest: wit_system_api::HttpResponse) -> http::Response { + http::Response { + status: guest.status, + headers: guest.headers.into_iter().map(http::Header::from).collect(), + body: guest.body, + } + } +} + +impl From for http::Header { + fn from(guest: wit_system_api::HttpHeader) -> http::Header { + http::Header::new(guest.name, guest.value) + } +} diff --git a/linera-sdk/src/contract/conversions_to_wit.rs b/linera-sdk/src/contract/conversions_to_wit.rs index f243d54a6aa6..e65c376c09f2 100644 --- a/linera-sdk/src/contract/conversions_to_wit.rs +++ b/linera-sdk/src/contract/conversions_to_wit.rs @@ -9,6 +9,7 @@ use linera_base::{ Amount, ApplicationPermissions, BlockHeight, Resources, SendMessageRequest, TimeDelta, Timestamp, }, + http, identifiers::{ Account, AccountOwner, ApplicationId, BytecodeId, ChainId, ChannelName, Destination, MessageId, Owner, StreamName, @@ -181,6 +182,46 @@ impl From for wit_system_api::Resources { } } +impl From for wit_system_api::HttpRequest { + fn from(request: http::Request) -> Self { + wit_system_api::HttpRequest { + method: request.method.into(), + url: request.url, + headers: request + .headers + .into_iter() + .map(http::Header::into) + .collect(), + body: request.body, + } + } +} + +impl From for wit_system_api::HttpMethod { + fn from(method: http::Method) -> Self { + match method { + http::Method::Get => wit_system_api::HttpMethod::Get, + http::Method::Post => wit_system_api::HttpMethod::Post, + http::Method::Put => wit_system_api::HttpMethod::Put, + http::Method::Delete => wit_system_api::HttpMethod::Delete, + http::Method::Head => wit_system_api::HttpMethod::Head, + http::Method::Options => wit_system_api::HttpMethod::Options, + http::Method::Connect => wit_system_api::HttpMethod::Connect, + http::Method::Patch => wit_system_api::HttpMethod::Patch, + http::Method::Trace => wit_system_api::HttpMethod::Trace, + } + } +} + +impl From for wit_system_api::HttpHeader { + fn from(header: http::Header) -> Self { + wit_system_api::HttpHeader { + name: header.name, + value: header.value, + } + } +} + impl From for wit_system_api::LogLevel { fn from(level: log::Level) -> Self { match level { diff --git a/linera-sdk/src/contract/runtime.rs b/linera-sdk/src/contract/runtime.rs index 4c289b3473ce..ecc6dbe9d163 100644 --- a/linera-sdk/src/contract/runtime.rs +++ b/linera-sdk/src/contract/runtime.rs @@ -8,6 +8,7 @@ use linera_base::{ data_types::{ Amount, ApplicationPermissions, BlockHeight, Resources, SendMessageRequest, Timestamp, }, + http, identifiers::{ Account, AccountOwner, ApplicationId, BytecodeId, ChainId, ChannelName, Destination, MessageId, Owner, StreamName, @@ -304,15 +305,15 @@ where serde_json::from_slice(&response).expect("Failed to deserialize service response") } - /// Makes a POST request to the given URL as an oracle and returns the answer, if any. + /// Makes an HTTP `request` as an oracle and returns the HTTP response. /// /// Should only be used with queries where it is very likely that all validators will receive /// the same response, otherwise most block proposals will fail. /// /// Cannot be used in fast blocks: A block using this call should be proposed by a regular /// owner, not a super owner. - pub fn http_post(&mut self, url: &str, content_type: &str, payload: Vec) -> Vec { - wit::http_post(url, content_type, &payload) + pub fn http_request(&mut self, request: http::Request) -> http::Response { + wit::perform_http_request(&request.into()).into() } /// Panics if the current time at block validation is `>= timestamp`. Note that block diff --git a/linera-sdk/src/contract/test_runtime.rs b/linera-sdk/src/contract/test_runtime.rs index df8472a82a84..84837a9ec09d 100644 --- a/linera-sdk/src/contract/test_runtime.rs +++ b/linera-sdk/src/contract/test_runtime.rs @@ -13,6 +13,7 @@ use linera_base::{ data_types::{ Amount, ApplicationPermissions, BlockHeight, Resources, SendMessageRequest, Timestamp, }, + http, identifiers::{ Account, AccountOwner, ApplicationId, BytecodeId, ChainId, ChannelName, Destination, MessageId, Owner, StreamName, @@ -60,7 +61,7 @@ where events: Vec<(StreamName, Vec, Vec)>, claim_requests: Vec, expected_service_queries: VecDeque<(ApplicationId, String, String)>, - expected_post_requests: VecDeque<(String, Vec, Vec)>, + expected_http_requests: VecDeque<(http::Request, http::Response)>, expected_read_data_blob_requests: VecDeque<(DataBlobHash, Vec)>, expected_assert_data_blob_exists_requests: VecDeque<(DataBlobHash, Option<()>)>, expected_open_chain_calls: @@ -109,7 +110,7 @@ where events: Vec::new(), claim_requests: Vec::new(), expected_service_queries: VecDeque::new(), - expected_post_requests: VecDeque::new(), + expected_http_requests: VecDeque::new(), expected_read_data_blob_requests: VecDeque::new(), expected_assert_data_blob_exists_requests: VecDeque::new(), expected_open_chain_calls: VecDeque::new(), @@ -809,10 +810,9 @@ where .push_back((application_id.forget_abi(), query, response)); } - /// Adds an expected `http_post` call, and the response it should return in the test. - pub fn add_expected_post_request(&mut self, url: String, payload: Vec, response: Vec) { - self.expected_post_requests - .push_back((url, payload, response)); + /// Adds an expected `http_request` call, and the response it should return in the test. + pub fn add_expected_http_request(&mut self, request: http::Request, response: http::Response) { + self.expected_http_requests.push_back((request, response)); } /// Adds an expected `read_data_blob` call, and the response it should return in the test. @@ -852,19 +852,17 @@ where serde_json::from_str(&response).expect("Failed to deserialize response") } - /// Makes a GET request to the given URL as an oracle and returns the JSON part, if any. + /// Makes an HTTP `request` as an oracle and returns the HTTP response. /// /// Should only be used with queries where it is very likely that all validators will receive /// the same response, otherwise most block proposals will fail. /// /// Cannot be used in fast blocks: A block using this call should be proposed by a regular /// owner, not a super owner. - pub fn http_post(&mut self, url: &str, payload: Vec) -> Vec { - let maybe_request = self.expected_post_requests.pop_front(); - let (expected_url, expected_payload, response) = - maybe_request.expect("Unexpected POST request"); - assert_eq!(*url, expected_url); - assert_eq!(payload, expected_payload); + pub fn http_request(&mut self, request: http::Request) -> http::Response { + let maybe_request = self.expected_http_requests.pop_front(); + let (expected_request, response) = maybe_request.expect("Unexpected HTTP request"); + assert_eq!(request, expected_request); response } diff --git a/linera-sdk/src/ethereum.rs b/linera-sdk/src/ethereum.rs index cabd810c187a..9045b283feb2 100644 --- a/linera-sdk/src/ethereum.rs +++ b/linera-sdk/src/ethereum.rs @@ -7,6 +7,7 @@ use std::fmt::Debug; use async_graphql::scalar; use async_trait::async_trait; +use linera_base::http; pub use linera_ethereum::{ client::EthereumQueries, common::{EthereumDataType, EthereumEvent}, @@ -42,12 +43,17 @@ impl JsonRpcClient for ContractEthereumClient { } async fn request_inner(&self, payload: Vec) -> Result, Self::Error> { - let content_type = "application/json"; - Ok(contract_system_api::http_post( - &self.url, - content_type, - &payload, - )) + let response = contract_system_api::perform_http_request( + &http::Request { + method: http::Method::Post, + url: self.url.clone(), + headers: Vec::from([http::Header::new("Content-Type", b"application/json")]), + body: payload, + } + .into(), + ); + + Ok(response.body) } } @@ -75,11 +81,16 @@ impl JsonRpcClient for ServiceEthereumClient { } async fn request_inner(&self, payload: Vec) -> Result, Self::Error> { - let content_type = "application/json"; - Ok(service_system_api::http_post( - &self.url, - content_type, - &payload, - )) + let response = service_system_api::perform_http_request( + &http::Request { + method: http::Method::Post, + url: self.url.clone(), + headers: Vec::from([http::Header::new("Content-Type", b"application/json")]), + body: payload, + } + .into(), + ); + + Ok(response.body) } } diff --git a/linera-sdk/src/lib.rs b/linera-sdk/src/lib.rs index 0344fe6e6942..00d201277bf2 100644 --- a/linera-sdk/src/lib.rs +++ b/linera-sdk/src/lib.rs @@ -48,7 +48,7 @@ pub use bcs; pub use linera_base::{ abi, data_types::{Resources, SendMessageRequest}, - ensure, + ensure, http, }; use linera_base::{ abi::{ContractAbi, ServiceAbi, WithContractAbi, WithServiceAbi}, diff --git a/linera-sdk/src/service/conversions_from_wit.rs b/linera-sdk/src/service/conversions_from_wit.rs index 88d0b0794a99..889dad655ce8 100644 --- a/linera-sdk/src/service/conversions_from_wit.rs +++ b/linera-sdk/src/service/conversions_from_wit.rs @@ -6,6 +6,7 @@ use linera_base::{ crypto::CryptoHash, data_types::{Amount, BlockHeight, Timestamp}, + http, identifiers::{AccountOwner, ApplicationId, BytecodeId, ChainId, MessageId, Owner}, }; @@ -98,3 +99,23 @@ impl From for Timestamp { Timestamp::from(timestamp.inner0) } } + +impl From for http::Response { + fn from(response: wit_system_api::HttpResponse) -> http::Response { + http::Response { + status: response.status, + headers: response + .headers + .into_iter() + .map(http::Header::from) + .collect(), + body: response.body, + } + } +} + +impl From for http::Header { + fn from(header: wit_system_api::HttpHeader) -> http::Header { + http::Header::new(header.name, header.value) + } +} diff --git a/linera-sdk/src/service/conversions_to_wit.rs b/linera-sdk/src/service/conversions_to_wit.rs index 25fc143ead9e..8bd32686f626 100644 --- a/linera-sdk/src/service/conversions_to_wit.rs +++ b/linera-sdk/src/service/conversions_to_wit.rs @@ -6,6 +6,7 @@ use linera_base::{ crypto::CryptoHash, data_types::BlockHeight, + http, identifiers::{AccountOwner, ApplicationId, BytecodeId, ChainId, MessageId, Owner}, }; @@ -98,3 +99,43 @@ impl From for wit_system_api::MessageId { } } } + +impl From for wit_system_api::HttpRequest { + fn from(request: http::Request) -> Self { + wit_system_api::HttpRequest { + method: request.method.into(), + url: request.url, + headers: request + .headers + .into_iter() + .map(http::Header::into) + .collect(), + body: request.body, + } + } +} + +impl From for wit_system_api::HttpMethod { + fn from(method: http::Method) -> Self { + match method { + http::Method::Get => wit_system_api::HttpMethod::Get, + http::Method::Post => wit_system_api::HttpMethod::Post, + http::Method::Put => wit_system_api::HttpMethod::Put, + http::Method::Delete => wit_system_api::HttpMethod::Delete, + http::Method::Head => wit_system_api::HttpMethod::Head, + http::Method::Options => wit_system_api::HttpMethod::Options, + http::Method::Connect => wit_system_api::HttpMethod::Connect, + http::Method::Patch => wit_system_api::HttpMethod::Patch, + http::Method::Trace => wit_system_api::HttpMethod::Trace, + } + } +} + +impl From for wit_system_api::HttpHeader { + fn from(header: http::Header) -> Self { + wit_system_api::HttpHeader { + name: header.name, + value: header.value, + } + } +} diff --git a/linera-sdk/src/service/runtime.rs b/linera-sdk/src/service/runtime.rs index bf5417d05ad5..7b93db7e7d0d 100644 --- a/linera-sdk/src/service/runtime.rs +++ b/linera-sdk/src/service/runtime.rs @@ -8,6 +8,7 @@ use std::sync::Mutex; use linera_base::{ abi::ServiceAbi, data_types::{Amount, BlockHeight, Timestamp}, + http, identifiers::{AccountOwner, ApplicationId, ChainId}, }; use serde::Serialize; @@ -152,6 +153,17 @@ where .expect("Failed to deserialize query response from application") } + /// Makes an HTTP request to the given URL as an oracle and returns the answer, if any. + /// + /// Should only be used with queries where it is very likely that all validators will receive + /// the same response, otherwise most block proposals will fail. + /// + /// Cannot be used in fast blocks: A block using this call should be proposed by a regular + /// owner, not a super owner. + pub fn http_request(&mut self, request: http::Request) -> http::Response { + wit::perform_http_request(&request.into()).into() + } + /// Fetches a blob of bytes from a given URL. pub fn fetch_url(&self, url: &str) -> Vec { wit::fetch_url(url) diff --git a/linera-sdk/src/service/test_runtime.rs b/linera-sdk/src/service/test_runtime.rs index 8bc609749609..e91dbafdbd6a 100644 --- a/linera-sdk/src/service/test_runtime.rs +++ b/linera-sdk/src/service/test_runtime.rs @@ -3,12 +3,16 @@ //! Runtime types to simulate interfacing with the host executing the service. -use std::{collections::HashMap, mem, sync::Mutex}; +use std::{ + collections::{HashMap, VecDeque}, + mem, + sync::Mutex, +}; use linera_base::{ abi::ServiceAbi, data_types::{Amount, BlockHeight, Timestamp}, - hex, + hex, http, identifiers::{AccountOwner, ApplicationId, ChainId}, }; use serde::{de::DeserializeOwned, Serialize}; @@ -28,6 +32,7 @@ where chain_balance: Mutex>, owner_balances: Mutex>>, query_application_handler: Mutex>, + expected_http_requests: VecDeque<(http::Request, http::Response)>, url_blobs: Mutex>>>, blobs: Mutex>>>, scheduled_operations: Mutex>>, @@ -58,6 +63,7 @@ where chain_balance: Mutex::new(None), owner_balances: Mutex::new(None), query_application_handler: Mutex::new(None), + expected_http_requests: VecDeque::new(), url_blobs: Mutex::new(None), blobs: Mutex::new(None), scheduled_operations: Mutex::new(vec![]), @@ -376,6 +382,25 @@ where .expect("Failed to deserialize query response from application") } + /// Adds an expected `http_request` call, and the response it should return in the test. + pub fn add_expected_http_request(&mut self, request: http::Request, response: http::Response) { + self.expected_http_requests.push_back((request, response)); + } + + /// Makes an HTTP `request` as an oracle and returns the HTTP response. + /// + /// Should only be used with queries where it is very likely that all validators will receive + /// the same response, otherwise most block proposals will fail. + /// + /// Cannot be used in fast blocks: A block using this call should be proposed by a regular + /// owner, not a super owner. + pub fn http_request(&mut self, request: http::Request) -> http::Response { + let maybe_request = self.expected_http_requests.pop_front(); + let (expected_request, response) = maybe_request.expect("Unexpected HTTP request"); + assert_eq!(request, expected_request); + response + } + /// Configures the blobs returned when fetching from URLs during the test. pub fn with_url_blobs(self, url_blobs: impl IntoIterator)>) -> Self { *self.url_blobs.lock().unwrap() = Some(url_blobs.into_iter().collect()); diff --git a/linera-sdk/wit/contract-system-api.wit b/linera-sdk/wit/contract-system-api.wit index 5add389bc10e..6d7c89ec6c89 100644 --- a/linera-sdk/wit/contract-system-api.wit +++ b/linera-sdk/wit/contract-system-api.wit @@ -26,7 +26,7 @@ interface contract-system-api { try-call-application: func(authenticated: bool, callee-id: application-id, argument: list) -> list; emit: func(name: stream-name, key: list, value: list); query-service: func(application-id: application-id, query: list) -> list; - http-post: func(query: string, content-type: string, payload: list) -> list; + perform-http-request: func(request: http-request) -> http-response; assert-before: func(timestamp: timestamp); read-data-blob: func(hash: crypto-hash) -> list; assert-data-blob-exists: func(hash: crypto-hash); @@ -105,6 +105,36 @@ interface contract-system-api { subscribers(channel-name), } + record http-header { + name: string, + value: list, + } + + enum http-method { + get, + post, + put, + delete, + head, + options, + connect, + patch, + trace, + } + + record http-request { + method: http-method, + url: string, + headers: list, + body: list, + } + + record http-response { + status: u16, + headers: list, + body: list, + } + enum log-level { error, warn, diff --git a/linera-sdk/wit/service-system-api.wit b/linera-sdk/wit/service-system-api.wit index 02ad63f5f9c1..18bee9ea510b 100644 --- a/linera-sdk/wit/service-system-api.wit +++ b/linera-sdk/wit/service-system-api.wit @@ -14,7 +14,7 @@ interface service-system-api { schedule-operation: func(operation: list); try-query-application: func(application: application-id, argument: list) -> list; fetch-url: func(url: string) -> list; - http-post: func(query: string, content-type: string, payload: list) -> list; + perform-http-request: func(request: http-request) -> http-response; read-data-blob: func(hash: crypto-hash) -> list; assert-data-blob-exists: func(hash: crypto-hash); assert-before: func(timestamp: timestamp); @@ -54,6 +54,36 @@ interface service-system-api { part4: u64, } + record http-header { + name: string, + value: list, + } + + enum http-method { + get, + post, + put, + delete, + head, + options, + connect, + patch, + trace, + } + + record http-request { + method: http-method, + url: string, + headers: list, + body: list, + } + + record http-response { + status: u16, + headers: list, + body: list, + } + enum log-level { error, warn,